Skip to content
Open
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
2 changes: 1 addition & 1 deletion contracts/factory/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ test: build

build:
stellar contract build
@ls -l target/wasm32v1-none/release/*.wasm
@ls -l ../target/wasm32v1-none/release/factory.wasm
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@ls -l ../target/wasm32v1-none/release/factory.wasm
@ls -l target/wasm32v1-none/release/factory.wasm

Copilot uses AI. Check for mistakes.

fmt:
cargo fmt --all
Expand Down
36 changes: 36 additions & 0 deletions contracts/factory/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,39 @@ pub struct UserVelocityUpdated {
/// Max spend per transaction.
pub per_transaction_spend_limit: i128,
}

#[contractevent]
#[derive(Clone)]
pub struct OwnerUpdated {
/// Previous owner address.
pub old_owner: Address,
/// New owner address.
pub new_owner: Address,
}

#[contractevent]
#[derive(Clone)]
pub struct PauserUpdated {
/// Previous pauser address.
pub old_pauser: Address,
/// New pauser address.
pub new_pauser: Address,
}

#[contractevent]
#[derive(Clone)]
pub struct ContractUpgraded {
/// New WASM hash applied to the contract.
pub new_wasm_hash: BytesN<32>,
}

#[contractevent]
#[derive(Clone)]
pub struct IssuerUpgraded {
/// Issuer identifier.
pub issuer_id: BytesN<32>,
/// Token address associated with the issuer.
pub token: Address,
/// New WASM hash applied to the issuer contract.
pub new_wasm_hash: BytesN<32>,
}
147 changes: 142 additions & 5 deletions contracts/factory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ mod test;
mod velocity;

use crate::events::{
DebitorUpdated, DestinationUpdated, IssuerCreated, ManagedUpdated, TransferExecuted,
UserVelocityUpdated,
ContractUpgraded, DebitorUpdated, DestinationUpdated, IssuerCreated, IssuerUpgraded,
ManagedUpdated, OwnerUpdated, PauserUpdated, TransferExecuted, UserVelocityUpdated,
};
use pausable::{require_not_paused, Pausable};
use soroban_sdk::{
Expand All @@ -29,7 +29,7 @@ use storage::{
authorized_debitor, is_allowed_destination, issuer_address, issuer_manager, issuer_wasm_hash,
owner, pauser, remove_allowed_destination, remove_authorized_debitor, set_allowed_destination,
set_authorized_debitor, set_issuer_address, set_issuer_manager, set_issuer_wasm_hash,
set_owner, set_paused, set_pauser, user_velocity,
set_paused, user_velocity,
};
use velocity::{
set_user_velocity_limits, validate_and_update_user_velocity, validate_velocity_config,
Expand All @@ -47,6 +47,7 @@ pub trait IssuerContract {
destination: Address,
amount: i128,
);
fn upgrade(env: Env, new_wasm_hash: BytesN<32>);
}

#[contracterror]
Expand All @@ -72,6 +73,8 @@ pub enum FactoryError {
IssuerManagerNotFound = 8,
/// Issuer for the `(issuer_id, token)` pair already exists.
IssuerAlreadyExists = 9,
/// Caller is not authorized for this operation.
NotAuthorized = 10,
/// Operation requires the contract to be unpaused.
EnforcedPause = 1000,
/// Operation requires the contract to be paused.
Expand Down Expand Up @@ -110,8 +113,8 @@ impl Factory {
/// # Authorization
/// No runtime authorization check. This entrypoint is only callable at contract initialization.
pub fn __constructor(env: Env, owner: Address, pauser: Address, issuer_wasm_hash: BytesN<32>) {
set_owner(&env, &owner);
set_pauser(&env, &pauser);
storage::set_owner(&env, &owner);
storage::set_pauser(&env, &pauser);
set_paused(&env, false);
set_issuer_wasm_hash(&env, &issuer_wasm_hash);
}
Expand Down Expand Up @@ -457,6 +460,140 @@ impl Factory {
}
.publish(&env);
}

/// Returns the current owner address.
///
/// # Arguments
/// * `env` - Contract environment.
///
/// # Authorization
/// No authorization required.
///
/// # Returns
/// The current owner address.
pub fn get_owner(env: Env) -> Address {
owner(&env)
}

/// Returns the current pauser address.
///
/// # Arguments
/// * `env` - Contract environment.
///
/// # Authorization
/// No authorization required.
///
/// # Returns
/// The current pauser address.
pub fn get_pauser(env: Env) -> Address {
pauser(&env)
}

/// Transfers ownership to a new address.
///
/// # Arguments
/// * `env` - Contract environment.
/// * `new_owner` - Address to receive ownership.
///
/// # Authorization
/// Requires authorization from the current owner. Not gated by pause state.
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);
}
Comment on lines +500 to +511
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


/// Transfers the pauser role to a new address.
///
/// When called by the owner, the pause guard is skipped so the owner can
/// recover from a compromised pauser key that has frozen the contract.
/// When called by the current pauser, the contract must not be paused.
///
/// # 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);
}
Comment on lines +519 to +537
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can split it into two functions, or remove caller and check auth for both roles.


storage::set_pauser(&env, &new_pauser);

PauserUpdated {
old_pauser: current_pauser,
new_pauser,
}
.publish(&env);
}

/// Replaces this contract's WASM bytecode in-place, preserving its address and storage.
///
/// # Arguments
/// * `env` - Contract environment.
/// * `new_wasm_hash` - Hash of the uploaded WASM to install.
///
/// # Authorization
/// Requires authorization from the current owner. Not gated by pause state.
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);
}
Comment on lines +556 to +563
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

/// Upgrades an issuer contract's WASM bytecode in-place.
///
/// # Arguments
/// * `env` - Contract environment.
/// * `issuer_id` - Issuer identifier.
/// * `token` - Token address associated with the issuer.
/// * `new_wasm_hash` - Hash of the uploaded WASM to install on the issuer.
///
/// # Authorization
/// Requires authorization from the current owner. Not gated by pause state.
pub fn upgrade_issuer(
env: Env,
issuer_id: BytesN<32>,
token: Address,
new_wasm_hash: BytesN<32>,
) {
owner(&env).require_auth();

let Some(issuer_contract_address) = issuer_address(&env, &issuer_id, &token) else {
panic_with_error!(&env, FactoryError::IssuerNotFound);
};

let issuer = IssuerClient::new(&env, &issuer_contract_address);
issuer.upgrade(&new_wasm_hash);

IssuerUpgraded {
issuer_id,
token,
new_wasm_hash,
}
.publish(&env);
Comment on lines +587 to +595
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
}

#[contractimpl]
Expand Down
Loading
Loading