diff --git a/contract/CHANGELOG.md b/contract/CHANGELOG.md new file mode 100644 index 00000000..4366354f --- /dev/null +++ b/contract/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this Soroban workspace are documented here. + +## 0.1.1 + +### Changed + +- Workspace crate versions aligned to **0.1.1**. +- **`soroban-sdk`** workspace dependency updated to **21.7.7** (from 21.7.0), with `Cargo.lock` kept in sync. + +### Fixed + +- **myfans-token**: Temporary allowance TTL is extended so entries remain readable through `expiration_ledger + 1`, allowing `transfer_from` to return `AllowanceExpired` instead of `NoAllowance` after Soroban 21.7 TTL behavior; TTL is refreshed after partial `transfer_from` and `clear_allowance`. +- **Tests**: Adjusted for SDK/host semantics (contract-scoped event emission, ledger jumps vs instance TTL, `WithdrawEvent` decoding, empty auths via `mock_auths(&[])` / `set_auths`, and related integration cases). + +### Tooling + +- `scripts/release-check.sh` asserts the workspace `soroban-sdk` pin in `Cargo.toml` (update the script when bumping the SDK). diff --git a/contract/Cargo.lock b/contract/Cargo.lock index d5a26cdd..bac8f4b5 100644 --- a/contract/Cargo.lock +++ b/contract/Cargo.lock @@ -149,7 +149,7 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "content-access" -version = "0.1.0" +version = "0.1.1" dependencies = [ "myfans-lib", "soroban-sdk", @@ -157,7 +157,7 @@ dependencies = [ [[package]] name = "content-likes" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] @@ -190,21 +190,21 @@ dependencies = [ [[package]] name = "creator-deposits" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] [[package]] name = "creator-earnings" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] [[package]] name = "creator-registry" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] @@ -395,7 +395,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "earnings" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] @@ -725,21 +725,21 @@ dependencies = [ [[package]] name = "myfans-contract" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] [[package]] name = "myfans-lib" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] [[package]] name = "myfans-token" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] @@ -1368,7 +1368,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subscription" -version = "0.1.0" +version = "0.1.1" dependencies = [ "content-access", "creator-registry", @@ -1397,7 +1397,7 @@ dependencies = [ [[package]] name = "test-consumer" -version = "0.1.0" +version = "0.1.1" dependencies = [ "myfans-lib", "soroban-sdk", @@ -1456,7 +1456,7 @@ dependencies = [ [[package]] name = "treasury" -version = "0.1.0" +version = "0.1.1" dependencies = [ "soroban-sdk", ] diff --git a/contract/Cargo.toml b/contract/Cargo.toml index ff480d02..72dbd118 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -16,7 +16,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2021" # Minimum Supported Rust Version (MSRV). # soroban-sdk 21.x requires at least 1.74 (curve25519-dalek 4.x lower bound). @@ -29,7 +29,7 @@ description = "MyFans Soroban smart contracts." publish = false [workspace.dependencies] -soroban-sdk = "21.7.0" +soroban-sdk = "21.7.7" [profile.release] opt-level = "z" diff --git a/contract/contracts/content-access/src/content_query_test.rs b/contract/contracts/content-access/src/content_query_test.rs index 7127fbdf..56351000 100644 --- a/contract/contracts/content-access/src/content_query_test.rs +++ b/contract/contracts/content-access/src/content_query_test.rs @@ -3,7 +3,7 @@ #[cfg(test)] mod content_query_tests { use crate::{ContentAccess, ContentAccessClient}; - use soroban_sdk::{testutils::Address as _, Address, Env}; + use soroban_sdk::{testutils::Address as _, testutils::Ledger, Address, Env}; fn setup() -> (Env, Address) { let env = Env::default(); diff --git a/contract/contracts/content-access/src/lib.rs b/contract/contracts/content-access/src/lib.rs index e355fb10..8a0ddf0d 100644 --- a/contract/contracts/content-access/src/lib.rs +++ b/contract/contracts/content-access/src/lib.rs @@ -250,9 +250,13 @@ mod test { use super::*; use soroban_sdk::{ testutils::{Address as _, Events, Ledger}, - vec, Address, Env, Error as SorobanError, IntoVal, Symbol, TryIntoVal, + vec, + xdr::SorobanAuthorizationEntry, + Address, Env, Error as SorobanError, IntoVal, Symbol, TryIntoVal, }; + const EMPTY_AUTHS: &[SorobanAuthorizationEntry] = &[]; + // Mock token contract for testing #[contract] pub struct MockToken; @@ -727,11 +731,9 @@ mod test { env.mock_all_auths(); client.initialize(&admin, &token_id); - let env2 = Env::default(); - let client2 = ContentAccessClient::new(&env2, &contract_id); - - let creator = Address::generate(&env2); - client2.set_content_price(&creator, &1, &100); + let creator = Address::generate(&env); + env.set_auths(EMPTY_AUTHS); + client.set_content_price(&creator, &1, &100); } #[test] @@ -743,7 +745,7 @@ mod test { let admin = Address::generate(&env); let invalid_token_contract = env.register_contract(None, ContentAccess); - let invalid_token_address: Address = invalid_token_contract.into(); + let invalid_token_address = invalid_token_contract; let contract_id = env.register_contract(None, ContentAccess); let client = ContentAccessClient::new(&env, &contract_id); diff --git a/contract/contracts/creator-deposits/src/lib.rs b/contract/contracts/creator-deposits/src/lib.rs index 2dda559c..8c6502f2 100644 --- a/contract/contracts/creator-deposits/src/lib.rs +++ b/contract/contracts/creator-deposits/src/lib.rs @@ -195,7 +195,7 @@ mod test { client.deposit(&creator, &token, &1000); // Verify transfer was called with correct fee (50) - assert!(env.auths().len() > 0); + assert!(!env.auths().is_empty()); } #[test] diff --git a/contract/contracts/creator-earnings/src/test.rs b/contract/contracts/creator-earnings/src/test.rs index 048a82f4..41b13dfd 100644 --- a/contract/contracts/creator-earnings/src/test.rs +++ b/contract/contracts/creator-earnings/src/test.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - use super::*; use soroban_sdk::token::{Client as TokenClient, StellarAssetClient}; use soroban_sdk::{ @@ -182,28 +180,33 @@ fn withdraw_emits_event() { client.deposit(&depositor, &creator, &500); client.withdraw(&creator, &200); - // events().all() returns Vec<(contract_addr, topics: Vec, data: Val)> - let events = env.events().all(); - let withdraw_event = events.iter().find(|e| { - // e.1 = topics, e.2 = data - e.1.first().map_or(false, |t| { - t.try_into_val(&env).ok() == Some(Symbol::new(&env, "withdraw")) - }) - }); - - assert!(withdraw_event.is_some(), "withdraw event not emitted"); - - let event = withdraw_event.unwrap(); + let all_events = env.events().all(); + let mut withdraw_event: Option<( + Address, + soroban_sdk::Vec, + soroban_sdk::Val, + )> = None; + for i in 0..all_events.len() { + let evt = all_events.get(i).unwrap(); + let (id, topics, _data) = &evt; + if *id != client.address { + continue; + } + let t0: Option = topics.get(0).and_then(|v| v.try_into_val(&env).ok()); + if t0 == Some(Symbol::new(&env, "withdraw")) { + withdraw_event = Some(evt); + break; + } + } + + let event = withdraw_event.expect("withdraw event not emitted"); - // Assert topics: single symbol "withdraw" assert_eq!(event.1.len(), 1); - let topic_symbol: Symbol = event.1.first().unwrap().try_into_val(&env).unwrap(); + let topic_symbol: Symbol = event.1.get(0).unwrap().try_into_val(&env).unwrap(); assert_eq!(topic_symbol, Symbol::new(&env, "withdraw")); - // Assert data: (creator, amount, token) - let (event_creator, event_amount, event_token): (Address, i128, Address) = - event.2.try_into_val(&env).unwrap(); - assert_eq!(event_creator, creator); - assert_eq!(event_amount, 200); - assert_eq!(event_token, token_address); + let withdraw_data: WithdrawEvent = event.2.try_into_val(&env).unwrap(); + assert_eq!(withdraw_data.creator, creator); + assert_eq!(withdraw_data.amount, 200); + assert_eq!(withdraw_data.token, token_address); } diff --git a/contract/contracts/creator-registry/src/lib.rs b/contract/contracts/creator-registry/src/lib.rs index b67fc602..7e085c86 100644 --- a/contract/contracts/creator-registry/src/lib.rs +++ b/contract/contracts/creator-registry/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Symbol, + contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, }; use soroban_sdk::token::Client; @@ -173,4 +173,5 @@ impl CreatorRegistryContract { } } +#[cfg(test)] mod test; diff --git a/contract/contracts/creator-registry/src/test.rs b/contract/contracts/creator-registry/src/test.rs index c4a53a44..bdb3fb0b 100644 --- a/contract/contracts/creator-registry/src/test.rs +++ b/contract/contracts/creator-registry/src/test.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - use super::Error as ContractError; use super::*; use soroban_sdk::{ @@ -178,75 +176,6 @@ fn test_registration_ledger_key_helper_keeps_legacy_variant() { ); } -#[test] -fn test_update_creator_id_authorized() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, CreatorRegistryContract); - let client = CreatorRegistryContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let creator = Address::generate(&env); - - client.initialize(&admin); - client.register_creator(&creator, &creator, &111); - - client.update_creator_id(&creator, &creator, &222); - - assert_eq!(client.get_creator_id(&creator), Some(222)); - - let events = env.events().all(); - let found = events.iter().any(|event| { - let topic: soroban_sdk::Symbol = event.1.get(0).unwrap().try_into_val(&env).unwrap(); - topic == soroban_sdk::Symbol::new(&env, "creator_updated") - }); - assert!(found, "creator_updated event not emitted"); -} - -#[test] -fn test_update_creator_id_unauthorized_fails() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, CreatorRegistryContract); - let client = CreatorRegistryContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let creator = Address::generate(&env); - let rando = Address::generate(&env); - - client.initialize(&admin); - client.register_creator(&creator, &creator, &111); - - let result = client.try_update_creator_id(&rando, &creator, &222); - assert_eq!( - result, - Err(Ok(SorobanError::from_contract_error( - Error::Unauthorized as u32, - ))) - ); -} - -#[test] -fn test_update_creator_id_not_registered_fails() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register_contract(None, CreatorRegistryContract); - let client = CreatorRegistryContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let creator = Address::generate(&env); - - client.initialize(&admin); - - let result = client.try_update_creator_id(&admin, &creator, &222); - assert_eq!( - result, - Err(Ok(SorobanError::from_contract_error( - Error::NotRegistered as u32, - ))) - ); -} - // ─── Rate limit boundary tests (issue #320) ─────────────────────────────────── /// Advance the ledger sequence by `n` ledgers. @@ -399,6 +328,11 @@ fn rate_limit_is_per_caller_not_global() { fn first_registration_is_never_rate_limited() { let env = Env::default(); env.mock_all_auths(); + + // High ledger must be set before instance storage is written: jumping here + // after initialize would exceed the entry TTL and archive keys (SDK 21.7+). + env.ledger().with_mut(|li| li.sequence_number = 99_999); + let contract_id = env.register_contract(None, CreatorRegistryContract); let client = CreatorRegistryContractClient::new(&env, &contract_id); let admin = Address::generate(&env); @@ -406,9 +340,7 @@ fn first_registration_is_never_rate_limited() { client.initialize(&admin); - // Jump to a high ledger — no prior registration so limit cannot apply - env.ledger().with_mut(|li| li.sequence_number = 99_999); - + // No prior registration for admin — rate limit cannot apply client.register_creator(&admin, &creator, &99u64); assert_eq!( diff --git a/contract/contracts/earnings/Cargo.toml b/contract/contracts/earnings/Cargo.toml index 1e2e8496..da73440c 100644 --- a/contract/contracts/earnings/Cargo.toml +++ b/contract/contracts/earnings/Cargo.toml @@ -9,7 +9,7 @@ description.workspace = true publish.workspace = true [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } diff --git a/contract/contracts/earnings/src/lib.rs b/contract/contracts/earnings/src/lib.rs index 5323d1c7..ef62f743 100644 --- a/contract/contracts/earnings/src/lib.rs +++ b/contract/contracts/earnings/src/lib.rs @@ -82,4 +82,5 @@ impl Earnings { } } +#[cfg(test)] mod test; diff --git a/contract/contracts/earnings/src/test.rs b/contract/contracts/earnings/src/test.rs index 9411f06c..3482b8c9 100644 --- a/contract/contracts/earnings/src/test.rs +++ b/contract/contracts/earnings/src/test.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - use super::*; use soroban_sdk::{ testutils::{Address as _, Events}, diff --git a/contract/contracts/myfans-contract/src/test.rs b/contract/contracts/myfans-contract/src/test.rs index c55c80ba..4078a50e 100644 --- a/contract/contracts/myfans-contract/src/test.rs +++ b/contract/contracts/myfans-contract/src/test.rs @@ -1,7 +1,7 @@ -#![cfg(test)] use super::*; use soroban_sdk::{ - testutils::Address as _, testutils::Ledger, Address, Env, Error as SorobanError, + testutils::Address as _, testutils::Events, testutils::Ledger, Address, Env, + Error as SorobanError, Symbol, TryIntoVal, }; #[test] @@ -281,7 +281,7 @@ fn test_register_creator() { assert!(creator_info.is_some()); let info = creator_info.unwrap(); assert_eq!(info.creator_id, 1); - assert_eq!(info.is_verified, false); + assert!(!info.is_verified); } #[test] @@ -354,21 +354,21 @@ fn test_set_verified_updates_status() { // Verify initial state is not verified let info_before = client.get_creator(&creator).unwrap(); - assert_eq!(info_before.is_verified, false); + assert!(!info_before.is_verified); // Admin verifies the creator client.set_verified(&creator, &true); // Check verification status updated let info_after = client.get_creator(&creator).unwrap(); - assert_eq!(info_after.is_verified, true); + assert!(info_after.is_verified); assert_eq!(info_after.creator_id, 1); // Admin can also unverify client.set_verified(&creator, &false); let info_final = client.get_creator(&creator).unwrap(); - assert_eq!(info_final.is_verified, false); + assert!(!info_final.is_verified); } #[test] @@ -391,13 +391,13 @@ fn test_get_creator_returns_correct_tuple() { // Get creator info let info = client.get_creator(&creator).unwrap(); assert_eq!(info.creator_id, creator_id); - assert_eq!(info.is_verified, false); + assert!(!info.is_verified); // Verify and check again client.set_verified(&creator, &true); let info_verified = client.get_creator(&creator).unwrap(); assert_eq!(info_verified.creator_id, creator_id); - assert_eq!(info_verified.is_verified, true); + assert!(info_verified.is_verified); } #[test] @@ -478,7 +478,7 @@ fn test_non_admin_cannot_set_verified_reverts() { // Test that admin CAN set verified client.set_verified(&creator, &true); let info = client.get_creator(&creator).unwrap(); - assert_eq!(info.is_verified, true); + assert!(info.is_verified); } #[test] @@ -504,7 +504,7 @@ fn test_only_admin_signature_works_for_set_verified() { // Verify the admin can set verified status client.set_verified(&creator, &true); let info = client.get_creator(&creator).unwrap(); - assert_eq!(info.is_verified, true); + assert!(info.is_verified); // The security model is: // 1. set_verified calls admin.require_auth() @@ -757,11 +757,10 @@ fn test_transfer_event_schema_emitted_on_subscribe() { let creator = Address::generate(&env); let fan = Address::generate(&env); let fee_recipient = Address::generate(&env); - let asset = Address::generate(&env); // Register mock token contract so token::Client calls succeed let token_id = env.register_contract(None, MockToken); - let token_client = MockTokenClient::new(&env, &token_id); + let _token_client = MockTokenClient::new(&env, &token_id); let client = MyfansContractClient::new(&env, &contract_id); client.init(&admin, &500 /* 5% fee */, &fee_recipient); @@ -770,47 +769,26 @@ fn test_transfer_event_schema_emitted_on_subscribe() { // Subscribe — triggers both transfer legs client.subscribe(&fan, &1); - // Collect all events emitted by the contract let all_events = env.events().all(); + let mut transfer_count = 0usize; + let mut transfer_from_count = 0usize; + for i in 0..all_events.len() { + let (id, topics, _data) = all_events.get(i).unwrap(); + if id != contract_id { + continue; + } + let t0: Option = topics.get(0).and_then(|v| v.try_into_val(&env).ok()); + if t0 == Some(Symbol::new(&env, events::TOPIC_TRANSFER)) { + transfer_count += 1; + } + if t0 == Some(Symbol::new(&env, events::TOPIC_TRANSFER_FROM)) { + transfer_from_count += 1; + } + } - // Filter to events from our contract - let contract_events: soroban_sdk::Vec<_> = all_events - .iter() - .filter(|(id, _topics, _data)| *id == contract_id) - .collect(); - - // We expect at least the two transfer events + the "subscribed" event - // Verify "transfer" event is present with correct topic key - let transfer_events: soroban_sdk::Vec<_> = contract_events - .iter() - .filter(|(_id, topics, _data)| { - if let soroban_sdk::Val::Symbol(sym) = topics.get(0).unwrap() { - sym == Symbol::new(&env, events::TOPIC_TRANSFER) - } else { - false - } - }) - .collect(); - - assert!( - !transfer_events.is_empty(), - "expected at least one transfer event" - ); - - // Verify "transfer_from" event is present (fee leg) - let transfer_from_events: soroban_sdk::Vec<_> = contract_events - .iter() - .filter(|(_id, topics, _data)| { - if let soroban_sdk::Val::Symbol(sym) = topics.get(0).unwrap() { - sym == Symbol::new(&env, events::TOPIC_TRANSFER_FROM) - } else { - false - } - }) - .collect(); - + assert!(transfer_count > 0, "expected at least one transfer event"); assert!( - !transfer_from_events.is_empty(), + transfer_from_count > 0, "expected at least one transfer_from event" ); } @@ -830,63 +808,70 @@ fn test_transfer_and_transfer_from_share_same_data_shape() { let env = Env::default(); env.mock_all_auths(); + let contract_id = env.register_contract(None, MyfansContract); + let addr_a = Address::generate(&env); let addr_b = Address::generate(&env); let addr_c = Address::generate(&env); let asset = Address::generate(&env); - events::emit_transfer(&env, &asset, &addr_a, &addr_b, 500); - events::emit_transfer_from(&env, &asset, &addr_a, &addr_c, 50); + // Events must be published in contract context (matches subscribe path); + // soroban-sdk 21.7+ does not surface the same topic Val decoding otherwise. + env.as_contract(&contract_id, || { + events::emit_transfer(&env, &asset, &addr_a, &addr_b, 500); + events::emit_transfer_from(&env, &asset, &addr_a, &addr_c, 50); + }); let all_events = env.events().all(); - // Both events should parse to a (Address, Address, i128) data tuple - let transfer_evt = all_events - .iter() - .find(|(_id, topics, _data)| { - topics - .get(0) - .map(|v| { - if let soroban_sdk::Val::Symbol(sym) = v { - sym == Symbol::new(&env, events::TOPIC_TRANSFER) - } else { - false - } - }) - .unwrap_or(false) - }) - .expect("transfer event not found"); - - let transfer_from_evt = all_events - .iter() - .find(|(_id, topics, _data)| { - topics - .get(0) - .map(|v| { - if let soroban_sdk::Val::Symbol(sym) = v { - sym == Symbol::new(&env, events::TOPIC_TRANSFER_FROM) - } else { - false - } - }) - .unwrap_or(false) - }) - .expect("transfer_from event not found"); - - // Both events have the asset as topic[1] + let mut transfer_evt: Option<( + soroban_sdk::Address, + soroban_sdk::Vec, + soroban_sdk::Val, + )> = None; + let mut transfer_from_evt: Option<( + soroban_sdk::Address, + soroban_sdk::Vec, + soroban_sdk::Val, + )> = None; + for i in 0..all_events.len() { + let evt = all_events.get(i).unwrap(); + let (id, topics, _) = &evt; + if *id != contract_id { + continue; + } + let t0: Option = topics.get(0).and_then(|v| v.try_into_val(&env).ok()); + if t0 == Some(Symbol::new(&env, events::TOPIC_TRANSFER)) { + transfer_evt = Some(evt); + } else if t0 == Some(Symbol::new(&env, events::TOPIC_TRANSFER_FROM)) { + transfer_from_evt = Some(evt); + } + } + + let transfer_evt = transfer_evt.expect("transfer event not found"); + let transfer_from_evt = transfer_from_evt.expect("transfer_from event not found"); + let (_id1, topics1, data1) = transfer_evt; let (_id2, topics2, data2) = transfer_from_evt; - // topic[1] = asset in both cases - assert_eq!(topics1.get(1), topics2.get(1), "asset topic must match"); - - // data is (from, to, amount) — both events must have a 3-element tuple - // We verify this by confirming they deserialize to the same shape - let (from1, to1, amount1): (Address, Address, i128) = - soroban_sdk::xdr::FromXdr::from_xdr(&env, &data1).expect("transfer data must be a 3-tuple"); - let (from2, to2, amount2): (Address, Address, i128) = - soroban_sdk::xdr::FromXdr::from_xdr(&env, &data2) - .expect("transfer_from data must be a 3-tuple"); + let asset1: Address = topics1 + .get(1) + .expect("topic 1") + .try_into_val(&env) + .expect("asset topic"); + let asset2: Address = topics2 + .get(1) + .expect("topic 1") + .try_into_val(&env) + .expect("asset topic"); + assert_eq!(asset1, asset2, "asset topic must match"); + + let (from1, to1, amount1): (Address, Address, i128) = data1 + .try_into_val(&env) + .expect("transfer data must be a 3-tuple"); + let (from2, to2, amount2): (Address, Address, i128) = data2 + .try_into_val(&env) + .expect("transfer_from data must be a 3-tuple"); // Verify correct values were captured assert_eq!(from1, addr_a); @@ -919,36 +904,21 @@ fn test_zero_fee_emits_only_transfer_no_transfer_from() { client.subscribe(&fan, &1); let all_events = env.events().all(); - let contract_events: soroban_sdk::Vec<_> = all_events - .iter() - .filter(|(id, _t, _d)| *id == contract_id) - .collect(); - - let has_transfer = contract_events.iter().any(|(_id, topics, _data)| { - topics - .get(0) - .map(|v| { - if let soroban_sdk::Val::Symbol(sym) = v { - sym == Symbol::new(&env, events::TOPIC_TRANSFER) - } else { - false - } - }) - .unwrap_or(false) - }); - - let has_transfer_from = contract_events.iter().any(|(_id, topics, _data)| { - topics - .get(0) - .map(|v| { - if let soroban_sdk::Val::Symbol(sym) = v { - sym == Symbol::new(&env, events::TOPIC_TRANSFER_FROM) - } else { - false - } - }) - .unwrap_or(false) - }); + let mut has_transfer = false; + let mut has_transfer_from = false; + for i in 0..all_events.len() { + let (id, topics, _data) = all_events.get(i).unwrap(); + if id != contract_id { + continue; + } + let t0: Option = topics.get(0).and_then(|v| v.try_into_val(&env).ok()); + if t0 == Some(Symbol::new(&env, events::TOPIC_TRANSFER)) { + has_transfer = true; + } + if t0 == Some(Symbol::new(&env, events::TOPIC_TRANSFER_FROM)) { + has_transfer_from = true; + } + } assert!(has_transfer, "transfer event must still fire when fee is 0"); assert!( diff --git a/contract/contracts/myfans-contract/src/treasury_test.rs b/contract/contracts/myfans-contract/src/treasury_test.rs index b3434dbe..a8a974ea 100644 --- a/contract/contracts/myfans-contract/src/treasury_test.rs +++ b/contract/contracts/myfans-contract/src/treasury_test.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - use crate::treasury::{Treasury, TreasuryClient}; use soroban_sdk::{ testutils::{Address as _, MockAuth, MockAuthInvoke}, diff --git a/contract/contracts/myfans-lib/examples/usage.rs b/contract/contracts/myfans-lib/examples/usage.rs index d6c21df1..b6481773 100644 --- a/contract/contracts/myfans-lib/examples/usage.rs +++ b/contract/contracts/myfans-lib/examples/usage.rs @@ -56,3 +56,7 @@ mod test { assert!(!client.requires_payment(&ContentType::Free)); } } + +/// `examples/*.rs` must expose a `main` for the example binary target; contract +/// snippets above are exercised via the `#[cfg(test)]` module when running tests. +fn main() {} diff --git a/contract/contracts/myfans-lib/src/lib.rs b/contract/contracts/myfans-lib/src/lib.rs index 728f6af7..3cbdf03f 100644 --- a/contract/contracts/myfans-lib/src/lib.rs +++ b/contract/contracts/myfans-lib/src/lib.rs @@ -60,7 +60,6 @@ pub mod test_fixtures; #[cfg(test)] mod tests { use super::*; - use soroban_sdk::{Env, IntoVal, TryIntoVal}; #[test] fn test_subscription_status_values() { diff --git a/contract/contracts/myfans-lib/src/test_fixtures.rs b/contract/contracts/myfans-lib/src/test_fixtures.rs index 844b3461..d15e89b4 100644 --- a/contract/contracts/myfans-lib/src/test_fixtures.rs +++ b/contract/contracts/myfans-lib/src/test_fixtures.rs @@ -31,8 +31,10 @@ pub const TEST_TTL: u32 = 10_000_000; /// Central fixture that every cross-contract integration test should start from. /// /// ```rust +/// use myfans_lib::test_fixtures::TestEnv; +/// /// let f = TestEnv::new(); -/// f.token_admin.mint(&f.fan, &5_000); +/// f.mint(&f.fan, 5_000); /// ``` pub struct TestEnv { pub env: Env, diff --git a/contract/contracts/myfans-token/src/allowance_expiry_tests.rs b/contract/contracts/myfans-token/src/allowance_expiry_tests.rs index 630642a0..c48a1bbb 100644 --- a/contract/contracts/myfans-token/src/allowance_expiry_tests.rs +++ b/contract/contracts/myfans-token/src/allowance_expiry_tests.rs @@ -10,7 +10,7 @@ //! and deterministic. #[cfg(test)] -mod allowance_expiry_tests { +mod cases { use crate::{Error, MyFansToken, MyFansTokenClient}; use soroban_sdk::{ testutils::{Address as _, Ledger}, @@ -19,7 +19,7 @@ mod allowance_expiry_tests { /// Shared setup: deploy contract, mint `amount` to `owner`, approve `amount` /// for `spender` with `expiration_ledger = 100`. - fn setup(env: &Env, amount: i128) -> (MyFansTokenClient, Address, Address, Address) { + fn setup(env: &Env, amount: i128) -> (MyFansTokenClient<'_>, Address, Address, Address) { let contract_id = env.register_contract(None, MyFansToken); let client = MyFansTokenClient::new(env, &contract_id); diff --git a/contract/contracts/myfans-token/src/lib.rs b/contract/contracts/myfans-token/src/lib.rs index a84f9b38..64dc2917 100644 --- a/contract/contracts/myfans-token/src/lib.rs +++ b/contract/contracts/myfans-token/src/lib.rs @@ -50,6 +50,23 @@ pub struct MyFansToken; #[contractimpl] impl MyFansToken { + /// Temporary allowance entries must stay readable until at least one ledger + /// after `expiration_ledger`, so `transfer_from` can return [`Error::AllowanceExpired`] + /// instead of [`Error::NoAllowance`] when the logical allowance has expired. + fn bump_allowance_temp_ttl(env: &Env, key: &DataKey, expiration_ledger: u32) { + let seq = env.ledger().sequence(); + // Default temp TTL after `set` is typically 16; threshold must be > that + // so extend runs, and host requires threshold <= extend_to. + let extend_to = expiration_ledger + .saturating_sub(seq) + .saturating_add(2) + .max(17) + .min(env.storage().max_ttl()); + env.storage() + .temporary() + .extend_ttl(key, extend_to, extend_to); + } + /// Initialize the token contract with admin and initial supply /// /// # Arguments @@ -183,9 +200,9 @@ impl MyFansToken { expiration_ledger, }; - // Store and extend TTL for temporary storage + // Store and extend TTL for temporary storage (see bump_allowance_temp_ttl). env.storage().temporary().set(&key, &data); - env.storage().temporary().extend_ttl(&key, 100, 100); + Self::bump_allowance_temp_ttl(&env, &key, expiration_ledger); env.events() .publish((symbol_short!("approve"), from, spender), amount); @@ -226,6 +243,7 @@ impl MyFansToken { expiration_ledger: data.expiration_ledger, }; env.storage().temporary().set(&key, &new_allowance); + Self::bump_allowance_temp_ttl(&env, &key, data.expiration_ledger); } None => return Err(Error::NoAllowance), } @@ -259,6 +277,7 @@ impl MyFansToken { expiration_ledger: env.ledger().sequence(), }; env.storage().temporary().set(&key, &data); + Self::bump_allowance_temp_ttl(&env, &key, data.expiration_ledger); env.events() .publish((symbol_short!("approve"), from, spender), 0i128); } diff --git a/contract/contracts/myfans-token/src/test.rs b/contract/contracts/myfans-token/src/test.rs index 31478c4c..48eb30d8 100644 --- a/contract/contracts/myfans-token/src/test.rs +++ b/contract/contracts/myfans-token/src/test.rs @@ -484,13 +484,12 @@ fn test_clear_allowance_resets_to_zero() { #[should_panic(expected = "Unauthorized")] fn test_clear_allowance_unauthorized_fails() { let env = Env::default(); - // No mock_all_auths — auth is enforced + env.mock_all_auths(); let contract_id = env.register_contract(None, MyFansToken); let client = MyFansTokenClient::new(&env, &contract_id); let admin = Address::generate(&env); - env.mock_all_auths(); client.initialize( &admin, &String::from_str(&env, "T"), @@ -503,10 +502,10 @@ fn test_clear_allowance_unauthorized_fails() { client.mint(&owner, &1000); client.approve(&owner, &spender, &500, &100); - // Drop mock auths so the next call is unauthenticated - let env2 = Env::default(); - let client2 = MyFansTokenClient::new(&env2, &contract_id); - client2.clear_allowance(&owner, &spender); + // No matching auth for owner — require_auth inside clear_allowance must fail. + env.mock_auths(&[]); + + client.clear_allowance(&owner, &spender); } // ── Issue #317: fuzz-style balance tests ───────────────────────────────────── @@ -621,7 +620,7 @@ fn test_initialize() { let name = String::from_str(&env, "MyFans Token"); let symbol = String::from_str(&env, "MFAN"); let decimals: u32 = 7; - let initial_supply: i128 = 1_000_000_0000; // 1,000,000 with 7 decimals + let initial_supply: i128 = 10_000_000_000; // 1,000,000 with 7 decimals client.initialize(&admin, &name, &symbol, &decimals, &initial_supply); @@ -647,7 +646,7 @@ fn test_admin_view_returns_correct_address() { let name = String::from_str(&env, "MyFans Token"); let symbol = String::from_str(&env, "MFAN"); let decimals: u32 = 7; - let initial_supply: i128 = 1_000_000_0000; + let initial_supply: i128 = 10_000_000_000; client.initialize(&admin, &name, &symbol, &decimals, &initial_supply); @@ -667,7 +666,7 @@ fn test_set_admin_updates_admin() { let name = String::from_str(&env, "MyFans Token"); let symbol = String::from_str(&env, "MFAN"); let decimals: u32 = 7; - let initial_supply: i128 = 1_000_000_0000; + let initial_supply: i128 = 10_000_000_000; client.initialize(&admin, &name, &symbol, &decimals, &initial_supply); @@ -692,7 +691,7 @@ fn test_non_admin_cannot_set_admin() { let name = String::from_str(&env, "MyFans Token"); let symbol = String::from_str(&env, "MFAN"); let decimals: u32 = 7; - let initial_supply: i128 = 1_000_000_0000; + let initial_supply: i128 = 10_000_000_000; client.initialize(&admin, &name, &symbol, &decimals, &initial_supply); diff --git a/contract/contracts/subscription/src/test.rs b/contract/contracts/subscription/src/test.rs index c1bc6813..cd8961b1 100644 --- a/contract/contracts/subscription/src/test.rs +++ b/contract/contracts/subscription/src/test.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - use super::dummy_data::*; use super::*; use soroban_sdk::{ @@ -373,9 +371,9 @@ fn test_subscription_state_after_snapshot_restore() { let contract_id = client.address.clone(); let expected_expiry = env.ledger().sequence() + (DUMMY_INTERVAL_DAYS * LEDGERS_PER_DAY); - let sc_fan: ScAddress = fan.clone().try_into().unwrap(); - let sc_creator: ScAddress = creator.clone().try_into().unwrap(); - let sc_contract: ScAddress = contract_id.clone().try_into().unwrap(); + let sc_fan: ScAddress = fan.clone().into(); + let sc_creator: ScAddress = creator.clone().into(); + let sc_contract: ScAddress = contract_id.clone().into(); let snapshot = env.to_snapshot(); let env2 = Env::from_snapshot(snapshot); @@ -770,9 +768,9 @@ fn test_cancel_after_snapshot_restore() { assert!(client.is_subscriber(&fan, &creator)); let contract_id = client.address.clone(); - let sc_fan: ScAddress = fan.clone().try_into().unwrap(); - let sc_creator: ScAddress = creator.clone().try_into().unwrap(); - let sc_contract: ScAddress = contract_id.clone().try_into().unwrap(); + let sc_fan: ScAddress = fan.clone().into(); + let sc_creator: ScAddress = creator.clone().into(); + let sc_contract: ScAddress = contract_id.clone().into(); let snapshot = env.to_snapshot(); let env2 = Env::from_snapshot(snapshot); diff --git a/contract/contracts/subscription/tests/auth_matrix.rs b/contract/contracts/subscription/tests/auth_matrix.rs index 791212b5..63e8e319 100644 --- a/contract/contracts/subscription/tests/auth_matrix.rs +++ b/contract/contracts/subscription/tests/auth_matrix.rs @@ -24,6 +24,8 @@ use subscription::{MyfansContract, MyfansContractClient}; const EMPTY_AUTHS: &[SorobanAuthorizationEntry] = &[]; +const NO_EXPIRY: u64 = u64::MAX; + fn base_env() -> Env { let env = Env::default(); env.mock_all_auths(); @@ -67,7 +69,7 @@ fn setup_content<'a>(env: &'a Env, token_id: &Address, admin: &Address) -> Conte client } -fn setup_registry(env: &Env, admin: &Address) -> CreatorRegistryContractClient<'_> { +fn setup_registry<'a>(env: &'a Env, admin: &Address) -> CreatorRegistryContractClient<'a> { let id = env.register_contract(None, CreatorRegistryContract); let client = CreatorRegistryContractClient::new(env, &id); client.initialize(admin); @@ -704,7 +706,7 @@ fn content_unlock_valid_buyer_signs() { let env = base_env(); let (content, _token, _admin, buyer, creator) = content_setup(&env); content.set_content_price(&creator, &1u64, &500i128); - content.unlock_content(&buyer, &creator, &1u64); + content.unlock_content(&buyer, &creator, &1u64, &NO_EXPIRY); assert!(content.has_access(&buyer, &creator, &1u64)); } @@ -715,7 +717,7 @@ fn content_unlock_invalid_third_party_rejected() { let (content, _token, _admin, buyer, creator) = content_setup(&env); content.set_content_price(&creator, &1u64, &500i128); env.set_auths(EMPTY_AUTHS); - let result = content.try_unlock_content(&buyer, &creator, &1u64); + let result = content.try_unlock_content(&buyer, &creator, &1u64, &NO_EXPIRY); assert!( result.is_err(), "third party must not unlock on behalf of buyer" diff --git a/contract/contracts/subscription/tests/contract_integration.rs b/contract/contracts/subscription/tests/contract_integration.rs index 59e3e97f..42253626 100644 --- a/contract/contracts/subscription/tests/contract_integration.rs +++ b/contract/contracts/subscription/tests/contract_integration.rs @@ -9,6 +9,9 @@ use soroban_sdk::testutils::{Events, Ledger as _}; use soroban_sdk::{String, Symbol, TryIntoVal}; use subscription::{MyfansContract, MyfansContractClient}; +/// Far-future ledger seq so purchased access does not expire in these tests. +const NO_EXPIRY: u64 = u64::MAX; + // ── helpers ─────────────────────────────────────────────────────────────────── fn setup_token<'a>(f: &'a TestEnv) -> MyFansTokenClient<'a> { @@ -65,7 +68,7 @@ fn test_subscription_to_content_unlock_flow() { let content_id = 1u64; assert!(!content.has_access(&f.fan, &f.creator, &content_id)); content.set_content_price(&f.creator, &content_id, &500i128); - content.unlock_content(&f.fan, &f.creator, &content_id); + content.unlock_content(&f.fan, &f.creator, &content_id, &NO_EXPIRY); assert!(content.has_access(&f.fan, &f.creator, &content_id)); assert_eq!(token.balance(&f.fan), 500i128); @@ -140,13 +143,13 @@ fn test_shared_token_balance_consistency_across_contracts() { assert_eq!(token.balance(&f.fee_recipient), 50i128); content.set_content_price(&f.creator, &1u64, &500i128); - content.unlock_content(&f.fan, &f.creator, &1u64); + content.unlock_content(&f.fan, &f.creator, &1u64, &NO_EXPIRY); assert_eq!(token.balance(&f.fan), 1_500i128); assert_eq!(token.balance(&f.creator), 1_450i128); content.set_content_price(&f.creator, &2u64, &300i128); - content.unlock_content(&f.fan, &f.creator, &2u64); + content.unlock_content(&f.fan, &f.creator, &2u64, &NO_EXPIRY); assert_eq!(token.balance(&f.fan), 1_200i128); assert_eq!(token.balance(&f.creator), 1_750i128); @@ -185,10 +188,10 @@ fn test_duplicate_content_unlock_is_idempotent_via_shared_fixture() { sub.subscribe(&f.fan, &plan_id, &token.address); content.set_content_price(&f.creator, &1u64, &200i128); - content.unlock_content(&f.fan, &f.creator, &1u64); + content.unlock_content(&f.fan, &f.creator, &1u64, &NO_EXPIRY); let balance_after_first = token.balance(&f.fan); - content.unlock_content(&f.fan, &f.creator, &1u64); + content.unlock_content(&f.fan, &f.creator, &1u64, &NO_EXPIRY); assert_eq!( token.balance(&f.fan), balance_after_first, diff --git a/contract/scripts/release-check.sh b/contract/scripts/release-check.sh index cd5aae65..62f24f0e 100755 --- a/contract/scripts/release-check.sh +++ b/contract/scripts/release-check.sh @@ -65,6 +65,10 @@ run_step() { run_step "cargo fmt --check" \ cargo fmt --check --manifest-path "$ROOT_DIR/Cargo.toml" +# Pin is documented in contract/CHANGELOG.md; update this grep when bumping Soroban. +run_step "workspace soroban-sdk pin (Cargo.toml)" \ + bash -c 'grep -qxF "soroban-sdk = \"21.7.7\"" "'"$ROOT_DIR"'/Cargo.toml"' + # ── Step 2: Clippy ──────────────────────────────────────────────────────────── run_step "cargo clippy" \