EN-33 Add role management, ownership transfer, and contract upgradeability#2
EN-33 Add role management, ownership transfer, and contract upgradeability#2marwen-abid wants to merge 1 commit intodevelopfrom
Conversation
marwen-abid
commented
Apr 14, 2026
- Add get_owner() and get_pauser() query functions to expose the two top-level roles that were previously only readable internally.
- Add set_owner() for single-signer ownership transfer, gated by current owner auth. Not blocked by pause state.
- Add set_pauser() with dual-auth: callable by the current owner or the current pauser. When the owner calls it, the pause guard is skipped — this is the critical recovery path for a compromised pauser key that has frozen the contract. When the pauser calls it, require_not_paused is enforced.
- Add upgrade() on the factory (owner-gated) to replace WASM bytecode in-place via Soroban's native update_current_contract_wasm.
- Add upgrade() on the issuer contract (factory-gated) and upgrade_issuer() on the factory so the owner can upgrade deployed issuer contracts through the factory.
- New audit events: OwnerUpdated, PauserUpdated, ContractUpgraded, IssuerUpgraded.
There was a problem hiding this comment.
Pull request overview
This PR introduces explicit role management (owner/pauser), controlled role rotation, and upgradeability for the factory and deployed issuer contracts, along with new audit events to improve observability of administrative actions.
Changes:
- Added
get_owner/get_pauserqueries plusset_ownerand dual-authorityset_pauserrotation logic on the factory. - Added upgrade entrypoints:
upgrade(factory self-upgrade),upgrade_issuer(factory-triggered issuer upgrades), andupgradeon the issuer (factory-gated). - Added new audit events (
OwnerUpdated,PauserUpdated,ContractUpgraded,IssuerUpgraded) and expanded test coverage for role transitions and upgrade authorization.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
contracts/issuer/src/lib.rs |
Adds factory-gated in-place WASM upgrade entrypoint for issuer contracts. |
contracts/factory/src/lib.rs |
Adds role query/rotation APIs and factory + issuer upgrade entrypoints; emits new audit events. |
contracts/factory/src/events.rs |
Defines new contract events for role updates and upgrades. |
contracts/factory/src/test.rs |
Adds tests for new role APIs and upgrade authorization/success paths. |
contracts/factory/Makefile |
Adjusts the expected location of the built factory WASM artifact. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let issuer = IssuerClient::new(&env, &issuer_contract_address); | ||
| issuer.upgrade(&new_wasm_hash); | ||
|
|
||
| IssuerUpgraded { | ||
| issuer_id, | ||
| token, | ||
| new_wasm_hash, | ||
| } | ||
| .publish(&env); |
There was a problem hiding this comment.
upgrade_issuer emits an IssuerUpgraded audit event, but current tests only cover auth and success/failure paths and do not verify the event payload. Consider adding an event assertion to ensure issuer_id, token, and new_wasm_hash are logged correctly.
| /// # Arguments | ||
| /// * `env` - Contract environment. | ||
| /// * `caller` - Address invoking the operation (must be the current owner or pauser). | ||
| /// * `new_pauser` - Address to receive the pauser role. | ||
| /// | ||
| /// # Authorization | ||
| /// Requires authorization from `caller`, who must be the current owner or pauser. | ||
| pub fn set_pauser(env: Env, caller: Address, new_pauser: Address) { | ||
| let current_owner = owner(&env); | ||
| let current_pauser = pauser(&env); | ||
|
|
||
| if caller == current_owner { | ||
| caller.require_auth(); | ||
| } else if caller == current_pauser { | ||
| require_not_paused(&env); | ||
| caller.require_auth(); | ||
| } else { | ||
| panic_with_error!(&env, FactoryError::NotAuthorized); | ||
| } |
There was a problem hiding this comment.
set_pauser requires the caller to pass a caller argument, and the function’s behavior (pause guard bypass vs. enforced) depends on that parameter rather than the actual invoker. This is easy for clients to misuse and allows a transaction signed by the owner to bypass the pause guard even if the invoker is the pauser (or vice versa). Consider deriving the caller from the environment (e.g., the immediate invoker) and branching based on whether that invoker equals the stored owner or pauser, then requiring auth for that same invoker.
There was a problem hiding this comment.
We can split it into two functions, or remove caller and check auth for both roles.
| build: | ||
| stellar contract build | ||
| @ls -l target/wasm32v1-none/release/*.wasm | ||
| @ls -l ../target/wasm32v1-none/release/factory.wasm |
There was a problem hiding this comment.
The Makefile now checks for the built WASM at ../target/.../factory.wasm, but other build-time references in this crate (e.g., contract WASM imports in tests) are relative to a target directory under contracts/factory. Unless the build pipeline has been switched to a workspace-level target dir, this path change can make make build report a missing artifact even when the build succeeded. Align the ls path with the actual target dir used by your build/test tooling.
| @ls -l ../target/wasm32v1-none/release/factory.wasm | |
| @ls -l target/wasm32v1-none/release/factory.wasm |
| pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { | ||
| owner(&env).require_auth(); | ||
|
|
||
| env.deployer() | ||
| .update_current_contract_wasm(new_wasm_hash.clone()); | ||
|
|
||
| ContractUpgraded { new_wasm_hash }.publish(&env); | ||
| } |
There was a problem hiding this comment.
upgrade emits a ContractUpgraded audit event, but there is no test asserting that the event is emitted with the expected WASM hash. Adding an event assertion (similar to the OwnerUpdated / PauserUpdated tests) would help prevent regressions in audit logging.
| pub fn set_owner(env: Env, new_owner: Address) { | ||
| let current_owner = owner(&env); | ||
| current_owner.require_auth(); | ||
|
|
||
| storage::set_owner(&env, &new_owner); | ||
|
|
||
| OwnerUpdated { | ||
| old_owner: current_owner, | ||
| new_owner, | ||
| } | ||
| .publish(&env); | ||
| } |
There was a problem hiding this comment.
set_owner only requires auth from the current owner. If an incorrect new_owner is passed, ownership is irrevocably lost.
Bridge's own Solana contract (update_admin) mitigates this by requiring both the current and new admin to sign the same transaction. The equivalent here would be adding new_owner.require_auth()
Just something to consider.
| let factory: Address = env | ||
| .storage() | ||
| .instance() | ||
| .get(&DataKey::Factory) | ||
| .expect("factory not set"); | ||
| factory.require_auth(); |
There was a problem hiding this comment.
Worth extracting into a helper function?