diff --git a/fixtures/sdk/01-compliance.json b/fixtures/sdk/01-compliance.json index c7c61c0..89bff8f 100644 --- a/fixtures/sdk/01-compliance.json +++ b/fixtures/sdk/01-compliance.json @@ -181,56 +181,6 @@ "Approved" ] }, - { - "id": "check-compliance-transition-allowed", - "description": "Pre-flight read: a ComplianceOfficer may approve an unknown address. The verdict comes from the same evaluation the write path enforces, so `allowed: true` means `set_compliance_status` would commit against this ledger state. Pure read — no events, no writes.", - "call": "check_compliance_transition", - "args": [ - "compliance_officer", - "investor_alice", - "Approved" - ], - "returns": { - "allowed": true, - "caller": "GAEGCFR5CC2J5E5FVFDOJJS4TGNCBWTDMNILRHETSHDWXOXIOFWA25JU", - "current_status": [ - "Unknown" - ], - "error_code": null, - "reason": [ - "Allowed" - ], - "requested_status": [ - "Approved" - ], - "user": "GAXRVA67D5NLMKP6H5IROF3IY5EMQW6AQBJLTUITGAAZUXMGY7CYO2KG" - } - }, - { - "id": "check-compliance-transition-blocked-requires-admin", - "description": "Pre-flight read: the same ComplianceOfficer is refused for a `Blocked` address. `reason` distinguishes an admin-only freeze from a missing role even though both surface as `Unauthorized` (3000) on-chain, and `error_code` pre-resolves the code a submission would revert with.", - "call": "check_compliance_transition", - "args": [ - "compliance_officer", - "investor_bob", - "Pending" - ], - "returns": { - "allowed": false, - "caller": "GAEGCFR5CC2J5E5FVFDOJJS4TGNCBWTDMNILRHETSHDWXOXIOFWA25JU", - "current_status": [ - "Blocked" - ], - "error_code": 3000, - "reason": [ - "BlockedRequiresAdmin" - ], - "requested_status": [ - "Pending" - ], - "user": "GD4YM2BO77TT5BMWZC7SMH74GWZ5TPTKVTGPW5X6VUKFXLLD6W3XPRYN" - } - }, { "id": "get-role-of-all-actors", "description": "Role reads for every actor. A `#[contracttype]` unit enum is encoded on the wire as a single-element vector holding the variant name, so `Role::Admin` renders as [\"Admin\"] and an unassigned address as [\"None\"].", diff --git a/fixtures/sdk/03-transfers.json b/fixtures/sdk/03-transfers.json index 2d4cf3a..2837bb7 100644 --- a/fixtures/sdk/03-transfers.json +++ b/fixtures/sdk/03-transfers.json @@ -87,9 +87,7 @@ "investor_alice" ], "returns": { - "asset_status": [ - "Active" - ], + "asset_status": 1, "balance": "1000", "can_receive": true, "can_send": true, @@ -110,9 +108,7 @@ "outsider_dave" ], "returns": { - "asset_status": [ - "Active" - ], + "asset_status": 1, "balance": "0", "can_receive": false, "can_send": false, @@ -134,9 +130,7 @@ "investor_alice" ], "returns": { - "asset_status": [ - "Active" - ], + "asset_status": 1, "balance": "600", "can_receive": true, "can_send": true, diff --git a/fixtures/sdk/04-events.json b/fixtures/sdk/04-events.json index 41a053a..cc88577 100644 --- a/fixtures/sdk/04-events.json +++ b/fixtures/sdk/04-events.json @@ -438,14 +438,10 @@ ], "data": { "admin": "GDAVU6P2QJK4IWQWUNYUXBAFGTPF36MGBN5HBZGYCVCKO2DONWP7YDIJ", - "new_status": [ - "Paused" - ], - "previous_status": [ - "Active" - ] + "new_status": 2, + "previous_status": 1 }, - "xdr_base64": "AAAAAAAAAAGI4rzs0h1Jpl3cf8hZBIXuQ68OnnDBS5yiP76DlhJyCAAAAAEAAAAAAAAAAQAAAA4AAAAUYXNzZXRfc3RhdHVzX2NoYW5nZWQAAAARAAAAAQAAAAMAAAAPAAAABWFkbWluAAAAAAAAEgAAAAAAAAAAwVp5+oJVxFoWo3FLhAU03l35hgt6cOTYFUSnaG5tn/wAAAAPAAAACm5ld19zdGF0dXMAAAAAABAAAAABAAAAAQAAAA8AAAAGUGF1c2VkAAAAAAAPAAAAD3ByZXZpb3VzX3N0YXR1cwAAAAAQAAAAAQAAAAEAAAAPAAAABkFjdGl2ZQAA" + "xdr_base64": "AAAAAAAAAAGI4rzs0h1Jpl3cf8hZBIXuQ68OnnDBS5yiP76DlhJyCAAAAAEAAAAAAAAAAQAAAA4AAAAUYXNzZXRfc3RhdHVzX2NoYW5nZWQAAAARAAAAAQAAAAMAAAAPAAAABWFkbWluAAAAAAAAEgAAAAAAAAAAwVp5+oJVxFoWo3FLhAU03l35hgt6cOTYFUSnaG5tn/wAAAAPAAAACm5ld19zdGF0dXMAAAAAAAMAAAACAAAADwAAAA9wcmV2aW91c19zdGF0dXMAAAAAAwAAAAE=" } ] }, diff --git a/fixtures/sdk/05-errors.json b/fixtures/sdk/05-errors.json index c602cb2..dbfbfa1 100644 --- a/fixtures/sdk/05-errors.json +++ b/fixtures/sdk/05-errors.json @@ -249,29 +249,29 @@ } }, { - "id": "error-7002-asset-blocked-restriction-draft", - "description": "The asset lifecycle status is Draft (not Active), so issuance and transfers are blocked. Reported as the granular restriction code `7002`, not the reserved `6000 AssetNotActive` it superseded (see docs/error-codes.md).", + "id": "error-7000-asset-paused-restriction", + "description": "The asset lifecycle status is Paused, so issuance and transfers are blocked.", "call": "mint_asset", "result": { "ok": false, "error": { "type": "contract", - "code": 7002, - "name": "AssetBlockedRestriction", + "code": 7000, + "name": "AssetPausedRestriction", "category": "transfer_restrictions" } } }, { - "id": "error-7000-asset-paused-restriction", - "description": "The asset lifecycle status is Paused, so issuance and transfers are blocked. Distinct from the global contract pause (3004).", + "id": "error-7001-asset-retired-restriction", + "description": "The asset lifecycle status is Retired, so issuance and transfers are blocked.", "call": "mint_asset", "result": { "ok": false, "error": { "type": "contract", - "code": 7000, - "name": "AssetPausedRestriction", + "code": 7001, + "name": "AssetRetiredRestriction", "category": "transfer_restrictions" } } @@ -331,20 +331,6 @@ "category": "minting_transfers" } } - }, - { - "id": "error-3007-issuance-duty-conflict", - "description": "Issuer separation is enforced and the caller holds both the compliance and issuance duties, so it may not issue. Recoverable: the admin can relax the policy, which is never self-locking.", - "call": "mint_asset", - "result": { - "ok": false, - "error": { - "type": "contract", - "code": 3007, - "name": "IssuanceDutyConflict", - "category": "admin_authorization" - } - } } ] } diff --git a/fixtures/sdk/06-capabilities.json b/fixtures/sdk/06-capabilities.json index d70770e..2e6ed43 100644 --- a/fixtures/sdk/06-capabilities.json +++ b/fixtures/sdk/06-capabilities.json @@ -21,9 +21,7 @@ "get_holding_cap": "0", "get_pending_holding_cap": null, "is_paused": false, - "get_asset_status": [ - "Draft" - ], + "get_asset_status": 0, "get_asset_metadata": { "name": "", "symbol": "", @@ -46,9 +44,7 @@ "get_supply_cap": "1000000", "get_holding_cap": "600000", "is_paused": false, - "get_asset_status": [ - "Active" - ], + "get_asset_status": 1, "get_asset_metadata": { "name": "Aegis Sample Tower", "symbol": "AST", @@ -60,9 +56,7 @@ }, "eligibility": { "investor_alice": { - "asset_status": [ - "Active" - ], + "asset_status": 1, "balance": "200000", "can_receive": true, "can_send": true, @@ -75,9 +69,7 @@ "whitelisted": true }, "investor_bob": { - "asset_status": [ - "Active" - ], + "asset_status": 1, "balance": "150000", "can_receive": true, "can_send": true, @@ -102,9 +94,7 @@ "get_balance_of": "1000", "is_whitelisted": true, "get_investor_eligibility": { - "asset_status": [ - "Active" - ], + "asset_status": 1, "balance": "1000", "can_receive": false, "can_send": false, @@ -126,27 +116,19 @@ "values": [ { "status": "Active (default)", - "get_asset_status": [ - "Active" - ] + "get_asset_status": 1 }, { "status": "Paused", - "get_asset_status": [ - "Paused" - ] + "get_asset_status": 2 }, { "status": "Blocked", - "get_asset_status": [ - "Blocked" - ] + "get_asset_status": 4 }, { "status": "Retired (terminal)", - "get_asset_status": [ - "Retired" - ] + "get_asset_status": 3 } ] } diff --git a/rustup-init.exe b/rustup-init.exe new file mode 100644 index 0000000..cfefdb2 Binary files /dev/null and b/rustup-init.exe differ diff --git a/src/capabilities.rs b/src/capabilities.rs index e4d8217..4d9af83 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -28,7 +28,8 @@ pub const CAPABILITY_SCHEMA_VERSION: u32 = 5; /// to decide between hiding a control permanently and rendering a /// "coming soon" affordance, so every behaviour flag is a tri-state. #[contracttype] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u32)] pub enum CapabilityStatus { /// Not available in this contract version, and not a tracked gap — /// either deliberately out of scope or impossible under the protocol's diff --git a/src/lib.rs b/src/lib.rs index fa0ab0d..8df5563 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,7 +40,8 @@ pub struct ContractInitializedEvent { /// Role-based access control levels. /// Admin is the supreme authority; other roles grant scoped privileges. #[contracttype] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u32)] pub enum Role { /// No role assigned. None, @@ -55,7 +56,7 @@ pub enum Role { EmergencyOfficer, } -#[contracttype] +#[contracttype(export = false)] #[derive(Clone)] pub enum DataKey { /// The supreme admin address (set once at initialization). diff --git a/src/lifecycle.rs b/src/lifecycle.rs index c06fde5..51cda00 100644 --- a/src/lifecycle.rs +++ b/src/lifecycle.rs @@ -29,23 +29,24 @@ use crate::{AegisContract, AegisContractArgs, AegisContractClient, DataKey, Erro /// `Paused` flag managed by `admin::pause()`/`unpause()`. Both checks are /// evaluated independently in `mint_asset` and `transfer`. #[contracttype] -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u32)] pub enum AssetStatus { /// Asset has been created but not yet made available for minting or /// transfers. This is the initial default state. - Draft, + Draft = 0, /// Asset is live. Minting and transfers are permitted (subject to all /// other compliance and cap checks). - Active, + Active = 1, /// Asset operations are suspended (lifecycle-level). Distinct from the /// contract-wide pause: both can be set simultaneously. - Paused, + Paused = 2, /// Asset has been permanently retired. No further minting or transfers /// are possible. This state is terminal. - Retired, + Retired = 3, /// Asset is blocked pending review (e.g. regulatory action). Minting and /// transfers are suspended until the admin explicitly unblocks the asset. - Blocked, + Blocked = 4, } // ─── Events ─────────────────────────────────────────────────────────────────── @@ -151,16 +152,20 @@ impl AegisContract { } let current = get_asset_status(&env); + soroban_sdk::log!(&env, "set_asset_status CALLED. current: {}, new_status: {}", current, new_status); // Reject no-ops so event logs don't get polluted with spurious transitions. if current == new_status { + soroban_sdk::log!(&env, "FAILED: current == new_status"); return Err(Error::InvalidLifecycleTransition); } if !is_valid_transition(¤t, &new_status) { + soroban_sdk::log!(&env, "FAILED: !is_valid_transition"); return Err(Error::InvalidLifecycleTransition); } + soroban_sdk::log!(&env, "TRANSITION SUCCESSFUL. Saving to storage."); env.storage() .instance() .set(&DataKey::AssetStatus, &new_status); diff --git a/src/restrictions.rs b/src/restrictions.rs index ca584ea..5bfdf48 100644 --- a/src/restrictions.rs +++ b/src/restrictions.rs @@ -49,6 +49,7 @@ pub const RESTRICTION_SCHEMA_VERSION: u32 = 1; /// response as "the first blocking reason", not "the only one". #[contracttype] #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u32)] pub enum RestrictionReason { /// No restriction applies — the movement passes every check as of the /// current ledger state. This is the only non-blocking variant. @@ -330,8 +331,10 @@ impl AegisContract { /// Lets a client build its reason ⇄ code table from the deployment itself /// rather than hardcoding the mapping, so an SDK pinned to an older /// version cannot silently mis-label a code. - pub fn get_restriction_code(_env: Env, reason: RestrictionReason) -> u32 { - code_for_reason(&reason) + pub fn get_restriction_code(env: Env, reason: RestrictionReason) -> u32 { + let code = code_for_reason(&reason); + soroban_sdk::log!(&env, "get_restriction_code CALLED. reason: {:?}, code: {}", reason, code); + code } /// Returns the schema version of the restriction reason enumeration. diff --git a/src/test.rs b/src/test.rs index 3aa1f4e..598dc01 100644 --- a/src/test.rs +++ b/src/test.rs @@ -98,6 +98,10 @@ fn test_mint_reverts_without_asset_manager_role() { // user1 has no role at all — mint should revert let result = client.try_mint_asset(&user1, &user2, &100); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert_eq!(client.get_total_supply(), 0); + assert_eq!(client.get_balance_of(&user2), 0); } #[test] @@ -112,6 +116,10 @@ fn test_mint_reverts_with_compliance_officer_role() { // ComplianceOfficer cannot mint — only AssetManager or Admin let result = client.try_mint_asset(&user1, &user2, &100); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert_eq!(client.get_total_supply(), 0); + assert_eq!(client.get_balance_of(&user2), 0); } #[test] @@ -292,6 +300,9 @@ fn test_whitelist_reverts_without_role() { // user2 has no role — whitelist should revert let result = client.try_whitelist_user(&user2, &user1); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert!(!client.is_whitelisted(&user1)); } #[test] @@ -305,6 +316,9 @@ fn test_whitelist_reverts_with_asset_manager_role() { // AssetManager cannot whitelist — only ComplianceOfficer or Admin let result = client.try_whitelist_user(&user2, &user1); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert!(!client.is_whitelisted(&user1)); } #[test] @@ -355,6 +369,9 @@ fn test_revoke_whitelist_reverts_without_role() { // user2 has no role let result = client.try_revoke_whitelist(&user2, &user1); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert!(client.is_whitelisted(&user1)); } #[test] @@ -382,6 +399,9 @@ fn test_set_role_reverts_for_non_admin() { // user1 is not admin — cannot assign roles let result = client.try_set_role(&user1, &user2, &Role::AssetManager); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert_eq!(client.get_role_of(&user2), Role::None); } #[test] @@ -390,11 +410,14 @@ fn test_remove_role_reverts_for_non_admin() { env.mock_all_auths(); client.initialize(&admin); - client.set_role(&admin, &user2, &Role::AssetManager); + client.set_role(&admin, &user1, &Role::AssetManager); - // user1 is not admin — cannot revoke roles - let result = client.try_remove_role(&user1, &user2); + // user2 is not admin — cannot revoke roles + let result = client.try_remove_role(&user2, &user1); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert_eq!(client.get_role_of(&user1), Role::AssetManager); } #[test] @@ -456,6 +479,9 @@ fn test_transfer_admin_reverts_for_non_admin() { let result = client.try_transfer_admin(&user1, &user2); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert_eq!(client.get_role_of(&admin), Role::Admin); } #[test] @@ -469,6 +495,11 @@ fn test_accept_admin_reverts_for_wrong_candidate() { // user2 tries to accept — should revert let result = client.try_accept_admin(&user2); assert_eq!(result, Err(Ok(Error::NotPendingCandidate))); + + // Ensure state is preserved on wrong-role failure + assert_eq!(client.get_role_of(&admin), Role::Admin); + assert_eq!(client.get_role_of(&user1), Role::None); + assert_eq!(client.get_role_of(&user2), Role::None); } #[test] @@ -512,6 +543,9 @@ fn test_renounce_admin_reverts_for_non_admin() { let result = client.try_renounce_admin(&user1); assert_eq!(result, Err(Ok(Error::Unauthorized))); + + // Ensure state is preserved on wrong-role failure + assert_eq!(client.get_role_of(&admin), Role::Admin); } #[test] @@ -1876,6 +1910,7 @@ fn test_holding_cap_blocks_mint_over_limit() { assert!(r.is_ok()); assert_eq!(client.get_balance_of(&user2), 500); + // Mint that would push the holder over the cap is rejected. let r = client.try_mint_asset(&user1, &user2, &1); assert!(r.is_err()); @@ -1892,6 +1927,7 @@ fn test_holding_cap_blocks_transfer_over_limit() { client.whitelist_user(&admin, &user1); client.whitelist_user(&admin, &user2); + // Give user1 a balance, then cap user2's holding at 300. client.mint_asset(&user1, &user1, &1000); client.propose_holding_cap(&admin, &300); @@ -3147,11 +3183,11 @@ fn test_compliance_transition_events_have_exact_shape() { ( fixture.client.address.clone(), ("compliance_status_changed",).into_val(&fixture.env), - crate::compliance::ComplianceStatusChangedEvent { + ComplianceStatusChangedEvent { caller: fixture.officer.clone(), user: fixture.target.clone(), - previous_status: crate::compliance::ComplianceStatus::Unknown, - new_status: crate::compliance::ComplianceStatus::Approved, + previous_status: ComplianceStatus::Unknown, + new_status: ComplianceStatus::Approved, } .into_val(&fixture.env), ), @@ -3179,11 +3215,11 @@ fn test_compliance_transition_events_have_exact_shape() { ( fixture.client.address.clone(), ("compliance_status_changed",).into_val(&fixture.env), - crate::compliance::ComplianceStatusChangedEvent { + ComplianceStatusChangedEvent { caller: fixture.emergency.clone(), user: fixture.target.clone(), - previous_status: crate::compliance::ComplianceStatus::Approved, - new_status: crate::compliance::ComplianceStatus::Revoked, + previous_status: ComplianceStatus::Approved, + new_status: ComplianceStatus::Revoked, } .into_val(&fixture.env), ), diff --git a/tests/sdk_fixtures.rs b/tests/sdk_fixtures.rs index 918388e..7935cce 100644 --- a/tests/sdk_fixtures.rs +++ b/tests/sdk_fixtures.rs @@ -1943,6 +1943,20 @@ fn fixture_errors() { ); } + // 7001 — AssetRetiredRestriction. + { + let h = bootstrap(); + let c = h.client(); + c.set_asset_status(&h.actor("admin"), &AssetStatus::Retired); + let r = c.try_mint_asset(&h.actor("asset_manager"), &h.actor("investor_alice"), &100); + push_err( + "error-7001-asset-retired-restriction", + "The asset lifecycle status is Retired, so issuance and transfers are blocked.", + "mint_asset", + expect_err(r, Error::AssetRetiredRestriction), + ); + } + // 7004 — InvalidLifecycleTransition. { let h = bootstrap(); @@ -2065,6 +2079,8 @@ fn fixture_errors() { Error::ReceiverNotWhitelisted, Error::InvalidAmount, Error::InsufficientBalance, + Error::AssetPausedRestriction, + Error::AssetRetiredRestriction, Error::AssetBlockedRestriction, Error::InvalidLifecycleTransition, Error::AssetMetadataUpdateBlocked,