From 3c03ea9cd90fe60126cb33133ead79b678ffb603 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 09:58:43 +0200 Subject: [PATCH 01/11] feat(console): create ufo (raw canister) --- src/console/src/api/factory.rs | 19 +++++- src/console/src/constants.rs | 1 + src/console/src/factory/impls.rs | 13 +++- src/console/src/factory/mod.rs | 1 + src/console/src/factory/ufo.rs | 81 +++++++++++++++++++++++++ src/console/src/fees/init.rs | 10 ++- src/console/src/lib.rs | 1 + src/console/src/rates/init.rs | 7 +++ src/console/src/rates/services.rs | 4 ++ src/console/src/segments/store.rs | 2 +- src/console/src/types.rs | 1 + src/libs/shared/src/constants/shared.rs | 1 + src/libs/shared/src/impls.rs | 1 + src/libs/shared/src/types.rs | 15 +++++ src/observatory/src/impls.rs | 1 + 15 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/console/src/factory/ufo.rs diff --git a/src/console/src/api/factory.rs b/src/console/src/api/factory.rs index 3d1379194a..d112490e2e 100644 --- a/src/console/src/api/factory.rs +++ b/src/console/src/api/factory.rs @@ -1,12 +1,13 @@ use crate::factory::mission_control::create_mission_control as create_mission_control_console; use crate::factory::orbiter::create_orbiter as create_orbiter_console; use crate::factory::satellite::create_satellite as create_satellite_console; +use crate::factory::ufo::create_ufo; use candid::Principal; use ic_cdk_macros::update; use junobuild_shared::ic::api::caller; use junobuild_shared::ic::UnwrapOrTrap; use junobuild_shared::types::interface::{ - CreateMissionControlArgs, CreateOrbiterArgs, CreateSatelliteArgs, + CreateMissionControlArgs, CreateOrbiterArgs, CreateSatelliteArgs, CreateSegmentArgs, }; #[update] @@ -33,3 +34,19 @@ async fn create_orbiter(args: CreateOrbiterArgs) -> Principal { create_orbiter_console(caller, args).await.unwrap_or_trap() } + +#[update] +async fn create_segment(args: CreateSegmentArgs) -> Principal { + let caller = caller(); + + let result = match args { + CreateSegmentArgs::Satellite(args) => create_satellite_console(caller, args).await, + CreateSegmentArgs::MissionControl(args) => { + create_mission_control_console(caller, args).await + } + CreateSegmentArgs::Orbiter(args) => create_orbiter_console(caller, args).await, + CreateSegmentArgs::Ufo(args) => create_ufo(caller, args).await, + }; + + result.unwrap_or_trap() +} diff --git a/src/console/src/constants.rs b/src/console/src/constants.rs index 76b93a25b2..5dfbc771b2 100644 --- a/src/console/src/constants.rs +++ b/src/console/src/constants.rs @@ -13,6 +13,7 @@ pub const SATELLITE_CREATION_FEE_CYCLES: CyclesTokens = CyclesTokens::from_e12s( pub const ORBITER_CREATION_FEE_CYCLES: CyclesTokens = CyclesTokens::from_e12s(3_000_000_000_000); pub const MISSION_CONTROL_CREATION_FEE_CYCLES: CyclesTokens = CyclesTokens::from_e12s(3_000_000_000_000); +pub const UFO_CREATION_FEE_CYCLES: CyclesTokens = CyclesTokens::from_e12s(3_000_000_000_000); // 1 ICP but also the default credit - i.e. a mission control starts with one credit. // A credit which can be used to start one satellite or one orbiter. diff --git a/src/console/src/factory/impls.rs b/src/console/src/factory/impls.rs index 073c3c830d..e634bbc2cd 100644 --- a/src/console/src/factory/impls.rs +++ b/src/console/src/factory/impls.rs @@ -2,7 +2,7 @@ use crate::factory::types::CanisterCreator; use crate::factory::types::CreateSegmentArgs; use candid::Principal; use junobuild_shared::types::interface::{ - CreateMissionControlArgs, CreateOrbiterArgs, CreateSatelliteArgs, + CreateMissionControlArgs, CreateOrbiterArgs, CreateSatelliteArgs, CreateUfoArgs, }; use junobuild_shared::types::state::{AccessKeyId, UserId}; @@ -63,3 +63,14 @@ impl From for CreateSegmentArgs { } } } + +impl From for CreateSegmentArgs { + fn from(args: CreateUfoArgs) -> Self { + Self { + // Unlike Satellite and Orbiter, or same as Mission Control, Ufo (raw canister) can only be + // spin using credits or ICRC-2 transfer from. + block_index: None, + subnet_id: args.subnet_id, + } + } +} diff --git a/src/console/src/factory/mod.rs b/src/console/src/factory/mod.rs index 22157225a9..4be2a0bfa9 100644 --- a/src/console/src/factory/mod.rs +++ b/src/console/src/factory/mod.rs @@ -5,4 +5,5 @@ mod orchestrator; pub mod satellite; mod services; mod types; +pub mod ufo; mod utils; diff --git a/src/console/src/factory/ufo.rs b/src/console/src/factory/ufo.rs new file mode 100644 index 0000000000..1ce252e342 --- /dev/null +++ b/src/console/src/factory/ufo.rs @@ -0,0 +1,81 @@ +use crate::accounts::get_existing_account; +use crate::constants::FREEZING_THRESHOLD_ONE_YEAR; +use crate::factory::orchestrator::create_segment_with_account; +use crate::factory::services::payment::{process_payment_cycles, refund_payment_cycles}; +use crate::factory::types::CanisterCreator; +use crate::factory::utils::controllers::update_mission_control_controllers; +use crate::fees::get_factory_fee; +use crate::rates::increment_ufo_rate; +use crate::segments::add_segment as add_segment_store; +use crate::types::ledger::Fee; +use crate::types::state::{Segment, SegmentKey, StorableSegmentKind}; +use candid::{Nat, Principal}; +use junobuild_shared::constants::shared::CREATE_UFO_CYCLES; +use junobuild_shared::ic::api::id; +use junobuild_shared::mgmt::cmc::create_canister_with_cmc; +use junobuild_shared::mgmt::ic::create_canister_with_ic_mgmt; +use junobuild_shared::mgmt::types::cmc::SubnetId; +use junobuild_shared::mgmt::types::ic::CreateCanisterInitSettingsArg; +use junobuild_shared::types::interface::CreateUfoArgs; +use junobuild_shared::types::state::{SegmentKind, UserId}; + +pub async fn create_ufo(caller: Principal, args: CreateUfoArgs) -> Result { + let account = get_existing_account(&caller)?; + + let name = args.name.clone(); + let creator: CanisterCreator = CanisterCreator::User((account.owner, None)); + + let fee = get_factory_fee(&SegmentKind::Ufo)?.fee_cycles; + + let canister_id = create_segment_with_account( + create_raw_canister, + process_payment_cycles, + refund_payment_cycles, + &increment_ufo_rate, + Fee::Cycles(fee), + &account, + creator, + args.into(), + ) + .await?; + + add_segment(&account.owner, &canister_id, &name); + + Ok(canister_id) +} + +async fn create_raw_canister( + creator: CanisterCreator, + subnet_id: Option, +) -> Result { + let CanisterCreator::User((user_id, _)) = creator else { + return Err("Mission Control cannot create an UFO".to_string()); + }; + + // We temporarily use the Console as a controller to create the canister but + // remove it as soon as it is spin. + let temporary_init_controllers = Vec::from([id(), user_id]); + + let create_settings_arg = CreateCanisterInitSettingsArg { + controllers: temporary_init_controllers, + freezing_threshold: Nat::from(FREEZING_THRESHOLD_ONE_YEAR), + }; + + let ufo_id = if let Some(subnet_id) = subnet_id { + create_canister_with_cmc(&create_settings_arg, CREATE_UFO_CYCLES, &subnet_id).await + } else { + create_canister_with_ic_mgmt(&create_settings_arg, CREATE_UFO_CYCLES).await + }?; + + // TODO: update controllers only user_id + update_mission_control_controllers(&ufo_id, &user_id).await?; + + Ok(ufo_id) +} + +fn add_segment(user: &UserId, canister_id: &Principal, name: &Option) { + let metadata = Segment::init_metadata(name); + let canister = Segment::new(canister_id, Some(metadata)); + let key = SegmentKey::from(user, canister_id, StorableSegmentKind::Ufo); + add_segment_store(&key, &canister) +} diff --git a/src/console/src/fees/init.rs b/src/console/src/fees/init.rs index f5c1d74a95..40fb9d589f 100644 --- a/src/console/src/fees/init.rs +++ b/src/console/src/fees/init.rs @@ -1,6 +1,6 @@ use crate::constants::{ MISSION_CONTROL_CREATION_FEE_CYCLES, ORBITER_CREATION_FEE_CYCLES, ORBITER_CREATION_FEE_ICP, - SATELLITE_CREATION_FEE_CYCLES, SATELLITE_CREATION_FEE_ICP, + SATELLITE_CREATION_FEE_CYCLES, SATELLITE_CREATION_FEE_ICP, UFO_CREATION_FEE_CYCLES, }; use crate::types::state::{FactoryFee, FactoryFees}; use ic_cdk::api::time; @@ -35,5 +35,13 @@ pub fn init_factory_fees() -> FactoryFees { updated_at: now, }, ), + ( + SegmentKind::Ufo, + FactoryFee { + fee_cycles: UFO_CREATION_FEE_CYCLES, + fee_icp: None, + updated_at: now, + }, + ), ]) } diff --git a/src/console/src/lib.rs b/src/console/src/lib.rs index d91c6186d6..b6014c2b4e 100644 --- a/src/console/src/lib.rs +++ b/src/console/src/lib.rs @@ -62,6 +62,7 @@ use junobuild_shared::types::domain::CustomDomains; use junobuild_shared::types::interface::CreateMissionControlArgs; use junobuild_shared::types::interface::CreateOrbiterArgs; use junobuild_shared::types::interface::CreateSatelliteArgs; +use junobuild_shared::types::interface::CreateSegmentArgs; use junobuild_shared::types::interface::{ AssertMissionControlCenterArgs, DeleteControllersArgs, GetCreateCanisterFeeArgs, SetControllersArgs, diff --git a/src/console/src/rates/init.rs b/src/console/src/rates/init.rs index f51bf03649..857da0fef8 100644 --- a/src/console/src/rates/init.rs +++ b/src/console/src/rates/init.rs @@ -35,5 +35,12 @@ pub fn init_factory_rates() -> FactoryRates { tokens: tokens.clone(), }, ), + ( + SegmentKind::Ufo, + FactoryRate { + config: DEFAULT_RATE_CONFIG, + tokens: tokens.clone(), + }, + ), ]) } diff --git a/src/console/src/rates/services.rs b/src/console/src/rates/services.rs index 18413b2799..40b5969cea 100644 --- a/src/console/src/rates/services.rs +++ b/src/console/src/rates/services.rs @@ -12,3 +12,7 @@ pub fn increment_mission_controls_rate() -> Result<(), String> { pub fn increment_orbiters_rate() -> Result<(), String> { increment_rate(&SegmentKind::Orbiter) } + +pub fn increment_ufo_rate() -> Result<(), String> { + increment_rate(&SegmentKind::Ufo) +} diff --git a/src/console/src/segments/store.rs b/src/console/src/segments/store.rs index fc84b1c42d..ad81729372 100644 --- a/src/console/src/segments/store.rs +++ b/src/console/src/segments/store.rs @@ -98,7 +98,7 @@ fn filter_segments_range( let end_key = SegmentKey { user: *user, // Fallback to last enum - segment_kind: segment_kind.clone().unwrap_or(StorableSegmentKind::Orbiter), + segment_kind: segment_kind.clone().unwrap_or(StorableSegmentKind::Ufo), segment_id: segment_id.unwrap_or(PRINCIPAL_MAX), }; diff --git a/src/console/src/types.rs b/src/console/src/types.rs index 48c4730efd..e27f440660 100644 --- a/src/console/src/types.rs +++ b/src/console/src/types.rs @@ -152,6 +152,7 @@ pub mod state { // For historical reasons, MissionControl is not stored in the segments stable tree // but within the Account structure Orbiter, + Ufo, } // On Apr. 4, 2026, someone exploited the free tier to spin up free canisters. diff --git a/src/libs/shared/src/constants/shared.rs b/src/libs/shared/src/constants/shared.rs index 90eb869a05..f9a4c62082 100644 --- a/src/libs/shared/src/constants/shared.rs +++ b/src/libs/shared/src/constants/shared.rs @@ -19,6 +19,7 @@ pub const IC_CREATE_CANISTER_CYCLES: u128 = 500_000_000_000u128; pub const CREATE_SATELLITE_CYCLES: u128 = 1_000_000_000_000; pub const CREATE_MISSION_CONTROL_CYCLES: u128 = 1_000_000_000_000; pub const CREATE_ORBITER_CYCLES: u128 = 1_000_000_000_000; +pub const CREATE_UFO_CYCLES: u128 = 1_000_000_000_000; // Reverse (CREA -> AERC) -> ASCII -> HEX -> LittleEndian // NNS canister create: CREA 0x41455243 diff --git a/src/libs/shared/src/impls.rs b/src/libs/shared/src/impls.rs index b944d9fe29..e8b04454af 100644 --- a/src/libs/shared/src/impls.rs +++ b/src/libs/shared/src/impls.rs @@ -20,6 +20,7 @@ impl Display for SegmentKind { SegmentKind::Satellite => write!(f, "Satellite"), SegmentKind::MissionControl => write!(f, "Mission Control"), SegmentKind::Orbiter => write!(f, "Orbiter"), + SegmentKind::Ufo => write!(f, "Ufo"), } } } diff --git a/src/libs/shared/src/types.rs b/src/libs/shared/src/types.rs index 3b29bbcdce..b5ad9983a1 100644 --- a/src/libs/shared/src/types.rs +++ b/src/libs/shared/src/types.rs @@ -63,6 +63,7 @@ pub mod state { Satellite, MissionControl, Orbiter, + Ufo, } #[derive(CandidType, Serialize, Deserialize, Clone)] @@ -117,6 +118,14 @@ pub mod interface { use ic_ledger_types::BlockIndex; use serde::{Deserialize, Serialize}; + #[derive(CandidType, Deserialize)] + pub enum CreateSegmentArgs { + Satellite(CreateSatelliteArgs), + MissionControl(CreateMissionControlArgs), + Orbiter(CreateOrbiterArgs), + Ufo(CreateUfoArgs), + } + #[derive(CandidType, Deserialize)] pub struct CreateOrbiterArgs { pub user: UserId, @@ -139,6 +148,12 @@ pub mod interface { pub name: Option, } + #[derive(CandidType, Deserialize)] + pub struct CreateUfoArgs { + pub subnet_id: Option, + pub name: Option, + } + #[derive(CandidType, Deserialize)] pub struct GetCreateCanisterFeeArgs { pub user: UserId, diff --git a/src/observatory/src/impls.rs b/src/observatory/src/impls.rs index 2ff485988f..88a5632bff 100644 --- a/src/observatory/src/impls.rs +++ b/src/observatory/src/impls.rs @@ -136,6 +136,7 @@ impl Notification { "https://console.juno.build/satellite/?s={}", self.segment.id ), + SegmentKind::Ufo => format!("https://console.juno.build/ufo/?u={}", self.segment.id), } } From c9ba54521dc75cc6d6cb03d9401c61b7bdfaa696 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 10:05:34 +0200 Subject: [PATCH 02/11] feat: generate did --- src/console/console.did | 12 ++++++++++-- src/declarations/console/console.did.d.ts | 18 ++++++++++++++++-- .../console/console.factory.certified.did.js | 13 +++++++++++++ .../console/console.factory.did.js | 13 +++++++++++++ .../console/console.factory.did.mjs | 13 +++++++++++++ .../observatory/observatory.did.d.ts | 6 +++++- .../observatory.factory.certified.did.js | 1 + .../observatory/observatory.factory.did.js | 1 + .../observatory/observatory.factory.did.mjs | 1 + src/observatory/observatory.did | 2 +- 10 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/console/console.did b/src/console/console.did index 819a5ef5c8..c9ec7ccc5d 100644 --- a/src/console/console.did +++ b/src/console/console.did @@ -100,6 +100,13 @@ type CreateSatelliteArgs = record { name : opt text; user : principal; }; +type CreateSegmentArgs = variant { + Ufo : CreateUfoArgs; + Orbiter : CreateOrbiterArgs; + MissionControl : CreateMissionControlArgs; + Satellite : CreateSatelliteArgs; +}; +type CreateUfoArgs = record { subnet_id : opt principal; name : opt text }; type CustomDomain = record { updated_at : nat64; created_at : nat64; @@ -310,7 +317,7 @@ type SegmentKey = record { segment_id : principal; segment_kind : StorableSegmentKind; }; -type SegmentKind = variant { Orbiter; MissionControl; Satellite }; +type SegmentKind = variant { Ufo; Orbiter; MissionControl; Satellite }; type SegmentsDeploymentOptions = record { orbiter : opt text; mission_control_version : opt text; @@ -353,7 +360,7 @@ type SetStorageConfig = record { redirects : opt vec record { text; StorageConfigRedirect }; }; type SignedDelegation = record { signature : blob; delegation : Delegation }; -type StorableSegmentKind = variant { Orbiter; Satellite }; +type StorableSegmentKind = variant { Ufo; Orbiter; Satellite }; type StorageConfig = record { iframe : opt StorageConfigIFrame; updated_at : opt nat64; @@ -418,6 +425,7 @@ service : () -> { create_mission_control : (CreateMissionControlArgs) -> (principal); create_orbiter : (CreateOrbiterArgs) -> (principal); create_satellite : (CreateSatelliteArgs) -> (principal); + create_segment : (CreateSegmentArgs) -> (principal); del_controllers : (DeleteControllersArgs) -> (); del_custom_domain : (text) -> (); delete_proposal_assets : (DeleteProposalAssets) -> (); diff --git a/src/declarations/console/console.did.d.ts b/src/declarations/console/console.did.d.ts index 45a6204717..1cbf302b6f 100644 --- a/src/declarations/console/console.did.d.ts +++ b/src/declarations/console/console.did.d.ts @@ -128,6 +128,15 @@ export interface CreateSatelliteArgs { name: [] | [string]; user: Principal; } +export type CreateSegmentArgs = + | { Ufo: CreateUfoArgs } + | { Orbiter: CreateOrbiterArgs } + | { MissionControl: CreateMissionControlArgs } + | { Satellite: CreateSatelliteArgs }; +export interface CreateUfoArgs { + subnet_id: [] | [Principal]; + name: [] | [string]; +} export interface CustomDomain { updated_at: bigint; created_at: bigint; @@ -373,7 +382,11 @@ export interface SegmentKey { segment_id: Principal; segment_kind: StorableSegmentKind; } -export type SegmentKind = { Orbiter: null } | { MissionControl: null } | { Satellite: null }; +export type SegmentKind = + | { Ufo: null } + | { Orbiter: null } + | { MissionControl: null } + | { Satellite: null }; export interface SegmentsDeploymentOptions { orbiter: [] | [string]; mission_control_version: [] | [string]; @@ -422,7 +435,7 @@ export interface SignedDelegation { signature: Uint8Array; delegation: Delegation; } -export type StorableSegmentKind = { Orbiter: null } | { Satellite: null }; +export type StorableSegmentKind = { Ufo: null } | { Orbiter: null } | { Satellite: null }; export interface StorageConfig { iframe: [] | [StorageConfigIFrame]; updated_at: [] | [bigint]; @@ -498,6 +511,7 @@ export interface _SERVICE { create_mission_control: ActorMethod<[CreateMissionControlArgs], Principal>; create_orbiter: ActorMethod<[CreateOrbiterArgs], Principal>; create_satellite: ActorMethod<[CreateSatelliteArgs], Principal>; + create_segment: ActorMethod<[CreateSegmentArgs], Principal>; del_controllers: ActorMethod<[DeleteControllersArgs], undefined>; del_custom_domain: ActorMethod<[string], undefined>; delete_proposal_assets: ActorMethod<[DeleteProposalAssets], undefined>; diff --git a/src/declarations/console/console.factory.certified.did.js b/src/declarations/console/console.factory.certified.did.js index d142a29726..1278b4a512 100644 --- a/src/declarations/console/console.factory.certified.did.js +++ b/src/declarations/console/console.factory.certified.did.js @@ -128,6 +128,16 @@ export const idlFactory = ({ IDL }) => { name: IDL.Opt(IDL.Text), user: IDL.Principal }); + const CreateUfoArgs = IDL.Record({ + subnet_id: IDL.Opt(IDL.Principal), + name: IDL.Opt(IDL.Text) + }); + const CreateSegmentArgs = IDL.Variant({ + Ufo: CreateUfoArgs, + Orbiter: CreateOrbiterArgs, + MissionControl: CreateMissionControlArgs, + Satellite: CreateSatelliteArgs + }); const DeleteControllersArgs = IDL.Record({ controllers: IDL.Vec(IDL.Principal) }); @@ -230,6 +240,7 @@ export const idlFactory = ({ IDL }) => { Err: GetDelegationError }); const SegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, MissionControl: IDL.Null, Satellite: IDL.Null @@ -439,6 +450,7 @@ export const idlFactory = ({ IDL }) => { items_length: IDL.Nat64 }); const StorableSegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, Satellite: IDL.Null }); @@ -528,6 +540,7 @@ export const idlFactory = ({ IDL }) => { create_mission_control: IDL.Func([CreateMissionControlArgs], [IDL.Principal], []), create_orbiter: IDL.Func([CreateOrbiterArgs], [IDL.Principal], []), create_satellite: IDL.Func([CreateSatelliteArgs], [IDL.Principal], []), + create_segment: IDL.Func([CreateSegmentArgs], [IDL.Principal], []), del_controllers: IDL.Func([DeleteControllersArgs], [], []), del_custom_domain: IDL.Func([IDL.Text], [], []), delete_proposal_assets: IDL.Func([DeleteProposalAssets], [], []), diff --git a/src/declarations/console/console.factory.did.js b/src/declarations/console/console.factory.did.js index 1493a47c2b..6b93dd2935 100644 --- a/src/declarations/console/console.factory.did.js +++ b/src/declarations/console/console.factory.did.js @@ -128,6 +128,16 @@ export const idlFactory = ({ IDL }) => { name: IDL.Opt(IDL.Text), user: IDL.Principal }); + const CreateUfoArgs = IDL.Record({ + subnet_id: IDL.Opt(IDL.Principal), + name: IDL.Opt(IDL.Text) + }); + const CreateSegmentArgs = IDL.Variant({ + Ufo: CreateUfoArgs, + Orbiter: CreateOrbiterArgs, + MissionControl: CreateMissionControlArgs, + Satellite: CreateSatelliteArgs + }); const DeleteControllersArgs = IDL.Record({ controllers: IDL.Vec(IDL.Principal) }); @@ -230,6 +240,7 @@ export const idlFactory = ({ IDL }) => { Err: GetDelegationError }); const SegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, MissionControl: IDL.Null, Satellite: IDL.Null @@ -439,6 +450,7 @@ export const idlFactory = ({ IDL }) => { items_length: IDL.Nat64 }); const StorableSegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, Satellite: IDL.Null }); @@ -528,6 +540,7 @@ export const idlFactory = ({ IDL }) => { create_mission_control: IDL.Func([CreateMissionControlArgs], [IDL.Principal], []), create_orbiter: IDL.Func([CreateOrbiterArgs], [IDL.Principal], []), create_satellite: IDL.Func([CreateSatelliteArgs], [IDL.Principal], []), + create_segment: IDL.Func([CreateSegmentArgs], [IDL.Principal], []), del_controllers: IDL.Func([DeleteControllersArgs], [], []), del_custom_domain: IDL.Func([IDL.Text], [], []), delete_proposal_assets: IDL.Func([DeleteProposalAssets], [], []), diff --git a/src/declarations/console/console.factory.did.mjs b/src/declarations/console/console.factory.did.mjs index 1493a47c2b..6b93dd2935 100644 --- a/src/declarations/console/console.factory.did.mjs +++ b/src/declarations/console/console.factory.did.mjs @@ -128,6 +128,16 @@ export const idlFactory = ({ IDL }) => { name: IDL.Opt(IDL.Text), user: IDL.Principal }); + const CreateUfoArgs = IDL.Record({ + subnet_id: IDL.Opt(IDL.Principal), + name: IDL.Opt(IDL.Text) + }); + const CreateSegmentArgs = IDL.Variant({ + Ufo: CreateUfoArgs, + Orbiter: CreateOrbiterArgs, + MissionControl: CreateMissionControlArgs, + Satellite: CreateSatelliteArgs + }); const DeleteControllersArgs = IDL.Record({ controllers: IDL.Vec(IDL.Principal) }); @@ -230,6 +240,7 @@ export const idlFactory = ({ IDL }) => { Err: GetDelegationError }); const SegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, MissionControl: IDL.Null, Satellite: IDL.Null @@ -439,6 +450,7 @@ export const idlFactory = ({ IDL }) => { items_length: IDL.Nat64 }); const StorableSegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, Satellite: IDL.Null }); @@ -528,6 +540,7 @@ export const idlFactory = ({ IDL }) => { create_mission_control: IDL.Func([CreateMissionControlArgs], [IDL.Principal], []), create_orbiter: IDL.Func([CreateOrbiterArgs], [IDL.Principal], []), create_satellite: IDL.Func([CreateSatelliteArgs], [IDL.Principal], []), + create_segment: IDL.Func([CreateSegmentArgs], [IDL.Principal], []), del_controllers: IDL.Func([DeleteControllersArgs], [], []), del_custom_domain: IDL.Func([IDL.Text], [], []), delete_proposal_assets: IDL.Func([DeleteProposalAssets], [], []), diff --git a/src/declarations/observatory/observatory.did.d.ts b/src/declarations/observatory/observatory.did.d.ts index 1e7716f05a..4ed1423f75 100644 --- a/src/declarations/observatory/observatory.did.d.ts +++ b/src/declarations/observatory/observatory.did.d.ts @@ -119,7 +119,11 @@ export interface Segment { metadata: [] | [Array<[string, string]>]; kind: SegmentKind; } -export type SegmentKind = { Orbiter: null } | { MissionControl: null } | { Satellite: null }; +export type SegmentKind = + | { Ufo: null } + | { Orbiter: null } + | { MissionControl: null } + | { Satellite: null }; export interface SetAccessKey { metadata: Array<[string, string]>; kind: [] | [AccessKeyKind]; diff --git a/src/declarations/observatory/observatory.factory.certified.did.js b/src/declarations/observatory/observatory.factory.certified.did.js index d7fda8452e..6056063cab 100644 --- a/src/declarations/observatory/observatory.factory.certified.did.js +++ b/src/declarations/observatory/observatory.factory.certified.did.js @@ -104,6 +104,7 @@ export const idlFactory = ({ IDL }) => { FailedCyclesDepositEmail: FailedCyclesDepositEmailNotification }); const SegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, MissionControl: IDL.Null, Satellite: IDL.Null diff --git a/src/declarations/observatory/observatory.factory.did.js b/src/declarations/observatory/observatory.factory.did.js index 39c8f2f854..6e4ca1c9eb 100644 --- a/src/declarations/observatory/observatory.factory.did.js +++ b/src/declarations/observatory/observatory.factory.did.js @@ -104,6 +104,7 @@ export const idlFactory = ({ IDL }) => { FailedCyclesDepositEmail: FailedCyclesDepositEmailNotification }); const SegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, MissionControl: IDL.Null, Satellite: IDL.Null diff --git a/src/declarations/observatory/observatory.factory.did.mjs b/src/declarations/observatory/observatory.factory.did.mjs index 39c8f2f854..6e4ca1c9eb 100644 --- a/src/declarations/observatory/observatory.factory.did.mjs +++ b/src/declarations/observatory/observatory.factory.did.mjs @@ -104,6 +104,7 @@ export const idlFactory = ({ IDL }) => { FailedCyclesDepositEmail: FailedCyclesDepositEmailNotification }); const SegmentKind = IDL.Variant({ + Ufo: IDL.Null, Orbiter: IDL.Null, MissionControl: IDL.Null, Satellite: IDL.Null diff --git a/src/observatory/observatory.did b/src/observatory/observatory.did index ec8a59c924..86bc1d16e1 100644 --- a/src/observatory/observatory.did +++ b/src/observatory/observatory.did @@ -78,7 +78,7 @@ type Segment = record { metadata : opt vec record { text; text }; kind : SegmentKind; }; -type SegmentKind = variant { Orbiter; MissionControl; Satellite }; +type SegmentKind = variant { Ufo; Orbiter; MissionControl; Satellite }; type SetAccessKey = record { metadata : vec record { text; text }; kind : opt AccessKeyKind; From a3c1fc7d8b1cab260fb3f7ff3e2989e71343ef14 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 11:23:13 +0200 Subject: [PATCH 03/11] feat: insert rates --- src/console/src/memory/lifecycle.rs | 4 +++ src/console/src/memory/mod.rs | 1 + src/console/src/memory/upgrade.rs | 54 +++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 src/console/src/memory/upgrade.rs diff --git a/src/console/src/memory/lifecycle.rs b/src/console/src/memory/lifecycle.rs index b1284c7ce7..4ba9be9e7b 100644 --- a/src/console/src/memory/lifecycle.rs +++ b/src/console/src/memory/lifecycle.rs @@ -2,6 +2,7 @@ use crate::cdn::certified_assets::upgrade::defer_init_certified_assets; use crate::cdn::lifecycle::init_cdn_storage_heap_state; use crate::fees::init_factory_fees; use crate::memory::manager::{get_memory_upgrades, init_stable_state, STATE}; +use crate::memory::upgrade::upgrade_init_ufo_fees_and_rates; use crate::rates::init::init_factory_rates; use crate::types::state::{HeapState, ReleasesMetadata, State}; use ciborium::{from_reader, into_writer}; @@ -57,4 +58,7 @@ fn post_upgrade() { STATE.with(|s| *s.borrow_mut() = state); defer_init_certified_assets(); + + // TODO: to be removed, one time upgrade + upgrade_init_ufo_fees_and_rates(); } diff --git a/src/console/src/memory/mod.rs b/src/console/src/memory/mod.rs index fdc1fa1308..8c00715d56 100644 --- a/src/console/src/memory/mod.rs +++ b/src/console/src/memory/mod.rs @@ -1,2 +1,3 @@ pub mod lifecycle; pub mod manager; +mod upgrade; diff --git a/src/console/src/memory/upgrade.rs b/src/console/src/memory/upgrade.rs new file mode 100644 index 0000000000..6d043f2119 --- /dev/null +++ b/src/console/src/memory/upgrade.rs @@ -0,0 +1,54 @@ +use crate::constants::UFO_CREATION_FEE_CYCLES; +use crate::store::mutate_heap_state; +use crate::types::state::{FactoryFee, FactoryFees, FactoryRate, FactoryRates, HeapState}; +use ic_cdk::api::time; +use junobuild_shared::ic::api::print; +use junobuild_shared::rate::constants::DEFAULT_RATE_CONFIG; +use junobuild_shared::rate::types::RateTokens; +use junobuild_shared::types::state::SegmentKind; + +pub fn upgrade_init_ufo_fees_and_rates() { + mutate_heap_state(|state| { + upgrade_ufo_fees(&mut state.factory_fees) + .unwrap_or_else(|err| print(format!("Error upgrading the Ufo fee: {:?}", err))); + + upgrade_ufo_rates(&mut state.factory_rates) + .unwrap_or_else(|err| print(format!("Error upgrading the Ufo rate: {:?}", err))); + }); +} + +fn upgrade_ufo_fees(factory_fees: &mut Option) -> Result<(), String> { + let fees = factory_fees + .as_mut() + .ok_or_else(|| "Factory fees not initialized".to_string())?; + + let fee = FactoryFee { + fee_cycles: UFO_CREATION_FEE_CYCLES, + fee_icp: None, + updated_at: time(), + }; + + fees.insert(SegmentKind::Ufo, fee); + + Ok(()) +} + +fn upgrade_ufo_rates(factory_rates: &mut Option) -> Result<(), String> { + let rates = factory_rates + .as_mut() + .ok_or_else(|| "Factory rates not initialized".to_string())?; + + let tokens: RateTokens = RateTokens { + tokens: 1, + updated_at: time(), + }; + + let rate = FactoryRate { + config: DEFAULT_RATE_CONFIG, + tokens: tokens.clone(), + }; + + rates.insert(SegmentKind::Ufo, rate); + + Ok(()) +} From 2c3b91c4f956defc16596f9e83fe9d0520a98ebd Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 11:28:39 +0200 Subject: [PATCH 04/11] feat: controller --- src/console/src/factory/ufo.rs | 13 ++++++++----- src/console/src/memory/upgrade.rs | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/console/src/factory/ufo.rs b/src/console/src/factory/ufo.rs index 1ce252e342..b9ac8e8f93 100644 --- a/src/console/src/factory/ufo.rs +++ b/src/console/src/factory/ufo.rs @@ -3,7 +3,9 @@ use crate::constants::FREEZING_THRESHOLD_ONE_YEAR; use crate::factory::orchestrator::create_segment_with_account; use crate::factory::services::payment::{process_payment_cycles, refund_payment_cycles}; use crate::factory::types::CanisterCreator; -use crate::factory::utils::controllers::update_mission_control_controllers; +use crate::factory::utils::controllers::{ + remove_console_controller, update_mission_control_controllers, +}; use crate::fees::get_factory_fee; use crate::rates::increment_ufo_rate; use crate::segments::add_segment as add_segment_store; @@ -48,13 +50,15 @@ async fn create_raw_canister( creator: CanisterCreator, subnet_id: Option, ) -> Result { - let CanisterCreator::User((user_id, _)) = creator else { + let CanisterCreator::User(_) = creator else { return Err("Mission Control cannot create an UFO".to_string()); }; + let controllers = creator.controllers(); + // We temporarily use the Console as a controller to create the canister but // remove it as soon as it is spin. - let temporary_init_controllers = Vec::from([id(), user_id]); + let temporary_init_controllers = [id()].into_iter().chain(controllers.clone()).collect(); let create_settings_arg = CreateCanisterInitSettingsArg { controllers: temporary_init_controllers, @@ -67,8 +71,7 @@ async fn create_raw_canister( create_canister_with_ic_mgmt(&create_settings_arg, CREATE_UFO_CYCLES).await }?; - // TODO: update controllers only user_id - update_mission_control_controllers(&ufo_id, &user_id).await?; + remove_console_controller(&ufo_id, &controllers).await?; Ok(ufo_id) } diff --git a/src/console/src/memory/upgrade.rs b/src/console/src/memory/upgrade.rs index 6d043f2119..23a9165e90 100644 --- a/src/console/src/memory/upgrade.rs +++ b/src/console/src/memory/upgrade.rs @@ -1,6 +1,6 @@ use crate::constants::UFO_CREATION_FEE_CYCLES; use crate::store::mutate_heap_state; -use crate::types::state::{FactoryFee, FactoryFees, FactoryRate, FactoryRates, HeapState}; +use crate::types::state::{FactoryFee, FactoryFees, FactoryRate, FactoryRates}; use ic_cdk::api::time; use junobuild_shared::ic::api::print; use junobuild_shared::rate::constants::DEFAULT_RATE_CONFIG; From 2b58be18ad94b34c3d01f706ead384bad1b7dcf8 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 11:51:13 +0200 Subject: [PATCH 05/11] test: migration config --- src/console/src/factory/ufo.rs | 2 +- .../upgrade/console.upgrade-v0-4-1.spec.ts | 10 +- .../upgrade/console.upgrade-v0-4-4.spec.ts | 102 ++++++++++++++++++ src/tests/utils/observatory-tests.utils.ts | 10 +- 4 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 src/tests/specs/console/upgrade/console.upgrade-v0-4-4.spec.ts diff --git a/src/console/src/factory/ufo.rs b/src/console/src/factory/ufo.rs index b9ac8e8f93..828e8986ff 100644 --- a/src/console/src/factory/ufo.rs +++ b/src/console/src/factory/ufo.rs @@ -4,7 +4,7 @@ use crate::factory::orchestrator::create_segment_with_account; use crate::factory::services::payment::{process_payment_cycles, refund_payment_cycles}; use crate::factory::types::CanisterCreator; use crate::factory::utils::controllers::{ - remove_console_controller, update_mission_control_controllers, + remove_console_controller, }; use crate::fees::get_factory_fee; use crate::rates::increment_ufo_rate; diff --git a/src/tests/specs/console/upgrade/console.upgrade-v0-4-1.spec.ts b/src/tests/specs/console/upgrade/console.upgrade-v0-4-1.spec.ts index 5c0cfb0bf1..cf3a99f0e8 100644 --- a/src/tests/specs/console/upgrade/console.upgrade-v0-4-1.spec.ts +++ b/src/tests/specs/console/upgrade/console.upgrade-v0-4-1.spec.ts @@ -12,11 +12,7 @@ import type { Principal } from '@icp-sdk/core/principal'; import { inject } from 'vitest'; import { CONSOLE_ID } from '../../../constants/console-tests.constants'; import { tick } from '../../../utils/pic-tests.utils'; -import { - CONSOLE_WASM_PATH, - controllersInitArgs, - downloadConsole -} from '../../../utils/setup-tests.utils'; +import { controllersInitArgs, downloadConsole } from '../../../utils/setup-tests.utils'; describe('Console > Upgrade > v0.4.0 -> v0.4.1', () => { let pic: PocketIc; @@ -28,9 +24,11 @@ describe('Console > Upgrade > v0.4.0 -> v0.4.1', () => { const upgrade = async () => { await tick(pic); + const destination = await downloadConsole({ junoVersion: '0.0.69', version: '0.4.1' }); + await pic.upgradeCanister({ canisterId, - wasm: CONSOLE_WASM_PATH, + wasm: destination, sender: controller.getPrincipal() }); }; diff --git a/src/tests/specs/console/upgrade/console.upgrade-v0-4-4.spec.ts b/src/tests/specs/console/upgrade/console.upgrade-v0-4-4.spec.ts new file mode 100644 index 0000000000..329d617900 --- /dev/null +++ b/src/tests/specs/console/upgrade/console.upgrade-v0-4-4.spec.ts @@ -0,0 +1,102 @@ +import { type ConsoleActor, idlFactoryConsole } from '$declarations'; +import { type Actor, PocketIc } from '@dfinity/pic'; +import { toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import type { Principal } from '@icp-sdk/core/principal'; +import { inject } from 'vitest'; +import { CONSOLE_ID } from '../../../constants/console-tests.constants'; +import { tick } from '../../../utils/pic-tests.utils'; +import { + CONSOLE_WASM_PATH, + controllersInitArgs, + downloadConsole +} from '../../../utils/setup-tests.utils'; + +describe('Console > Upgrade > v0.4.3 -> v0.4.4', () => { + let pic: PocketIc; + let actor: Actor; + let canisterId: Principal; + + const controller = Ed25519KeyIdentity.generate(); + + const upgrade = async () => { + await tick(pic); + + await pic.upgradeCanister({ + canisterId, + wasm: CONSOLE_WASM_PATH, + sender: controller.getPrincipal() + }); + }; + + beforeEach(async () => { + pic = await PocketIc.create(inject('PIC_URL')); + + const destination = await downloadConsole({ junoVersion: '0.0.72', version: '0.4.2' }); + + const { actor: c, canisterId: cId } = await pic.setupCanister({ + idlFactory: idlFactoryConsole, + wasm: destination, + arg: controllersInitArgs(controller), + sender: controller.getPrincipal(), + targetCanisterId: CONSOLE_ID + }); + + actor = c; + canisterId = cId; + + actor.setIdentity(controller); + }); + + afterEach(async () => { + await pic?.tearDown(); + }); + + it('should provide fees for ufo', async () => { + await upgrade(); + + const newActor = pic.createActor(idlFactoryConsole, canisterId); + newActor.setIdentity(controller); + + const { get_fee } = newActor; + + const feesWithIcp = [{ Satellite: null }, { Orbiter: null }]; + const feesWithoutIcp = [{ Ufo: null }, { MissionControl: null }]; + + for (const fee of feesWithIcp) { + await expect(get_fee(fee)).resolves.toEqual( + expect.objectContaining({ + fee_icp: toNullable({ e8s: 150_000_000n }), + fee_cycles: { e12s: 3_000_000_000_000n } + }) + ); + } + + for (const fee of feesWithoutIcp) { + await expect(get_fee(fee)).resolves.toEqual( + expect.objectContaining({ + fee_icp: toNullable(), + fee_cycles: { e12s: 3_000_000_000_000n } + }) + ); + } + }); + + it('should provide rates config for ufo', async () => { + await upgrade(); + + const newActor = pic.createActor(idlFactoryConsole, canisterId); + newActor.setIdentity(controller); + + const { get_rate_config } = newActor; + + const rates = [{ Ufo: null }, { MissionControl: null }, { Satellite: null }, { Orbiter: null }]; + + for (const rate of rates) { + await expect(get_rate_config(rate)).resolves.toEqual({ + max_tokens: 100n, + time_per_token_ns: 600_000_000n + }); + } + }); +}); diff --git a/src/tests/utils/observatory-tests.utils.ts b/src/tests/utils/observatory-tests.utils.ts index 15b4b1f612..83f478b269 100644 --- a/src/tests/utils/observatory-tests.utils.ts +++ b/src/tests/utils/observatory-tests.utils.ts @@ -1,4 +1,9 @@ -import type { ObservatoryActor, ObservatoryActor009, ObservatoryDid } from '$declarations'; +import { + type ObservatoryActor, + type ObservatoryActor009, + type ObservatoryDid, + type ObservatoryDid040 +} from '$declarations'; import type { PocketIc } from '@dfinity/pic'; import { assertNonNullish, nonNullish } from '@dfinity/utils'; import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; @@ -61,7 +66,8 @@ export const testDepositedCyclesNotification = async ({ segment: { id: mockMissionControlId, metadata: nonNullish(metadataName) ? [[['name', metadataName]]] : [], - kind + // TODO: cast was added for simplicity reasons when introducing Kind Ufo + kind: kind as ObservatoryDid040.SegmentKind }, user: Ed25519KeyIdentity.generate().getPrincipal() }); From 9ac95ab6e704e3ff610d25be1110bcda48d3dc55 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 12:35:57 +0200 Subject: [PATCH 06/11] test: create segment --- .../factory/console.factory.segments.spec.ts | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 src/tests/specs/console/factory/console.factory.segments.spec.ts diff --git a/src/tests/specs/console/factory/console.factory.segments.spec.ts b/src/tests/specs/console/factory/console.factory.segments.spec.ts new file mode 100644 index 0000000000..aea766fcc0 --- /dev/null +++ b/src/tests/specs/console/factory/console.factory.segments.spec.ts @@ -0,0 +1,347 @@ +import { type ConsoleActor, type ConsoleDid } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { fromNullable, fromNullishNullable, toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import type { Principal } from '@icp-sdk/core/principal'; +import { + CONSOLE_ID, + NO_ACCOUNT_ERROR_MSG, + TEST_FEES +} from '../../../constants/console-tests.constants'; +import { CYCLES_LEDGER_ID } from '../../../constants/ledger-tests.contants'; +import { setupConsole } from '../../../utils/console-tests.utils'; +import { approveToken, transferToken } from '../../../utils/ledger-tests.utils'; +import { tick } from '../../../utils/pic-tests.utils'; + +describe('Console > Factory > Segment', () => { + let pic: PocketIc; + let actor: Actor; + let controller: Ed25519KeyIdentity; + + beforeAll(async () => { + const { + pic: p, + actor: c, + controller: cO + } = await setupConsole({ + withApplyRateTokens: true, + withLedger: true, + withSegments: true, + withFee: true + }); + + pic = p; + + controller = cO; + + actor = c; + actor.setIdentity(controller); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + const assertMissionControl = async () => { + const { get_account } = actor; + + const account = await get_account(); + + const missionControlId = fromNullishNullable(fromNullable(account)?.mission_control_id); + + expect(missionControlId).not.toBeUndefined(); + }; + + const assertSegments = async ({ + title, + segmentIds + }: { + title: string; + segmentIds: Principal[]; + }) => { + const { list_segments } = actor; + + const segments = await list_segments({ + segment_kind: [ + title === 'UFO' + ? { Ufo: null } + : title === 'Orbiter' + ? { Orbiter: null } + : { Satellite: null } + ], + segment_id: [] + }); + + expect(segments).toHaveLength(segmentIds.length); + + for (const segmentId of segmentIds) { + expect( + segments.find(([_, { segment_id }]) => segment_id.toText() === segmentId.toText()) + ).not.toBeUndefined(); + } + }; + + describe.each([ + { + title: 'Satellite', + args: ({ user }: { user: Ed25519KeyIdentity }): ConsoleDid.CreateSegmentArgs => ({ + Satellite: { + user: user.getPrincipal(), + block_index: toNullable(), + name: toNullable(), + storage: toNullable(), + subnet_id: toNullable() + } + }) + }, + { + title: 'Orbiter', + args: ({ user }: { user: Ed25519KeyIdentity }): ConsoleDid.CreateSegmentArgs => ({ + Orbiter: { + user: user.getPrincipal(), + block_index: toNullable(), + name: toNullable(), + subnet_id: toNullable() + } + }) + }, + { + title: 'UFO', + args: (_: { user: Ed25519KeyIdentity }): ConsoleDid.CreateSegmentArgs => ({ + Ufo: { + name: toNullable(), + subnet_id: toNullable() + } + }) + } + ])('$title', ({ title, args }) => { + let user: Ed25519KeyIdentity; + + beforeEach(() => { + user = Ed25519KeyIdentity.generate(); + actor.setIdentity(user); + }); + + describe('Assertions', () => { + it('should fail with unknown account', async () => { + const { create_segment } = actor; + await expect(create_segment(args({ user }))).rejects.toThrow(NO_ACCOUNT_ERROR_MSG); + }); + }); + + describe('User', () => { + it('should create with user', async () => { + const { get_or_init_account } = actor; + await get_or_init_account(); + + const { create_segment } = actor; + + const id = await create_segment(args({ user })); + + expect(id).not.toBeUndefined(); + + await assertSegments({ + title, + segmentIds: [id] + }); + }); + + it('should fail with without credits and payment', async () => { + const { get_or_init_account, create_segment } = actor; + await get_or_init_account(); + + // First module works out + await create_segment(args({ user })); + + await pic.advanceTime(60_000); + await tick(pic); + + // Second requires payment + await expect(create_segment(args({ user }))).rejects.toThrow('InsufficientAllowance'); + }); + + it('should fail without enough payment', async () => { + const { get_or_init_account, create_segment } = actor; + await get_or_init_account(); + + // First module works out + await create_segment(args({ user })); + + await pic.advanceTime(60_000); + await tick(pic); + + await transferToken({ + pic, + owner: user.getPrincipal(), + ledgerId: CYCLES_LEDGER_ID, + amount: 2n * TEST_FEES.fee_cycles.e12s + }); + + await approveToken({ + pic, + owner: user, + spender: CONSOLE_ID, + ledgerId: CYCLES_LEDGER_ID, + amount: TEST_FEES.fee_cycles.e12s // Fees 100_000_000n are missing + }); + + await tick(pic); + + // Second requires full payment + await expect(create_segment(args({ user }))).rejects.toThrow('InsufficientAllowance'); + }); + + it('should succeed with payment', async () => { + const { get_or_init_account, create_segment } = actor; + await get_or_init_account(); + + // First module works out + const firstId = await create_segment(args({ user })); + + await pic.advanceTime(60_000); + await tick(pic); + + await transferToken({ + pic, + owner: user.getPrincipal(), + ledgerId: CYCLES_LEDGER_ID, + amount: 2n * TEST_FEES.fee_cycles.e12s + }); + + await approveToken({ + pic, + owner: user, + spender: CONSOLE_ID, + ledgerId: CYCLES_LEDGER_ID, + amount: TEST_FEES.fee_cycles.e12s + 100_000_000n + }); + + await tick(pic); + + // Second uses payment + const secondId = await create_segment(args({ user })); + + expect(secondId).not.toBeUndefined(); + + await assertSegments({ + title, + segmentIds: [firstId, secondId] + }); + }); + }); + }); + + describe('Mission Control', () => { + let user: Ed25519KeyIdentity; + + const args: ConsoleDid.CreateSegmentArgs = { MissionControl: { subnet_id: toNullable() } }; + const ufoArgs: ConsoleDid.CreateSegmentArgs = {Ufo: {name: toNullable(), subnet_id: toNullable()}}; + + beforeEach(() => { + user = Ed25519KeyIdentity.generate(); + actor.setIdentity(user); + }); + + describe('Assertions', () => { + it('should fail with unknown account', async () => { + const { create_segment } = actor; + await expect(create_segment(args)).rejects.toThrow(NO_ACCOUNT_ERROR_MSG); + }); + }); + + describe('User', () => { + it('should create with user', async () => { + const { get_or_init_account } = actor; + await get_or_init_account(); + + const { create_segment } = actor; + + const id = await create_segment(args); + + expect(id).not.toBeUndefined(); + + await assertMissionControl(); + }); + + it('should fail with without credits and payment', async () => { + const { get_or_init_account, create_segment } = actor; + await get_or_init_account(); + + // First module works out + await create_segment(ufoArgs); + + await pic.advanceTime(60_000); + await tick(pic); + + // Second requires payment + await expect(create_segment(args)).rejects.toThrow('InsufficientAllowance'); + }); + + it('should fail without enough payment', async () => { + const { get_or_init_account, create_segment } = actor; + await get_or_init_account(); + + // First module works out + await create_segment(ufoArgs); + + await pic.advanceTime(60_000); + await tick(pic); + + await transferToken({ + pic, + owner: user.getPrincipal(), + ledgerId: CYCLES_LEDGER_ID, + amount: 2n * TEST_FEES.fee_cycles.e12s + }); + + await approveToken({ + pic, + owner: user, + spender: CONSOLE_ID, + ledgerId: CYCLES_LEDGER_ID, + amount: TEST_FEES.fee_cycles.e12s // Fees 100_000_000n are missing + }); + + await tick(pic); + + // Second requires full payment + await expect(create_segment(args)).rejects.toThrow('InsufficientAllowance'); + }); + + it('should succeed with payment', async () => { + const { get_or_init_account, create_segment } = actor; + await get_or_init_account(); + + // First module works out + const firstId = await create_segment(ufoArgs); + + await pic.advanceTime(60_000); + await tick(pic); + + await transferToken({ + pic, + owner: user.getPrincipal(), + ledgerId: CYCLES_LEDGER_ID, + amount: 2n * TEST_FEES.fee_cycles.e12s + }); + + await approveToken({ + pic, + owner: user, + spender: CONSOLE_ID, + ledgerId: CYCLES_LEDGER_ID, + amount: TEST_FEES.fee_cycles.e12s + 100_000_000n + }); + + await tick(pic); + + // Second uses payment + const secondId = await create_segment(args); + + expect(secondId).not.toBeUndefined(); + + await assertMissionControl(); + }); + }); + }); +}); From fe52945183ec7ae48683d17a2a5822d08b49c5f9 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 12:40:50 +0200 Subject: [PATCH 07/11] test: fees --- src/tests/utils/console-tests.utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tests/utils/console-tests.utils.ts b/src/tests/utils/console-tests.utils.ts index 471d3d5fba..8c629efa88 100644 --- a/src/tests/utils/console-tests.utils.ts +++ b/src/tests/utils/console-tests.utils.ts @@ -701,6 +701,7 @@ export const setupConsole = async ({ await set_fee({ Satellite: null }, TEST_FEES); await set_fee({ Orbiter: null }, TEST_FEES); await set_fee({ MissionControl: null }, TEST_FEES); + await set_fee({ Ufo: null }, TEST_FEES); } return { pic, controller, actor, canisterId }; From c4fcb22b64265fe78c286724df6244bd2fc8be32 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 12:52:33 +0200 Subject: [PATCH 08/11] feat: assert controller --- .../factory/console.factory.segments.spec.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/tests/specs/console/factory/console.factory.segments.spec.ts b/src/tests/specs/console/factory/console.factory.segments.spec.ts index aea766fcc0..d8a0a14134 100644 --- a/src/tests/specs/console/factory/console.factory.segments.spec.ts +++ b/src/tests/specs/console/factory/console.factory.segments.spec.ts @@ -10,6 +10,7 @@ import { } from '../../../constants/console-tests.constants'; import { CYCLES_LEDGER_ID } from '../../../constants/ledger-tests.contants'; import { setupConsole } from '../../../utils/console-tests.utils'; +import { canisterStatus } from '../../../utils/ic-management-tests.utils'; import { approveToken, transferToken } from '../../../utils/ledger-tests.utils'; import { tick } from '../../../utils/pic-tests.utils'; @@ -81,6 +82,32 @@ describe('Console > Factory > Segment', () => { } }; + const assertController = async ({ + user, + canisterId, + controllers + }: { + user: Ed25519KeyIdentity; + canisterId: Principal; + controllers: Principal[]; + }) => { + const result = await canisterStatus({ + sender: user, + pic, + canisterId + }); + + const settings = result?.settings; + + expect(settings?.controllers).toHaveLength(controllers.length); + + for (const controller of controllers) { + expect( + settings?.controllers.find((c) => c.toText() === controller.toText()) + ).not.toBeUndefined(); + } + }; + describe.each([ { title: 'Satellite', @@ -146,6 +173,23 @@ describe('Console > Factory > Segment', () => { }); }); + it('should create with expected controllers', async () => { + const { get_or_init_account } = actor; + await get_or_init_account(); + + const { create_segment } = actor; + + const id = await create_segment(args({ user })); + + expect(id).not.toBeUndefined(); + + await assertController({ + user, + canisterId: id, + controllers: [user.getPrincipal()] + }); + }); + it('should fail with without credits and payment', async () => { const { get_or_init_account, create_segment } = actor; await get_or_init_account(); @@ -235,7 +279,9 @@ describe('Console > Factory > Segment', () => { let user: Ed25519KeyIdentity; const args: ConsoleDid.CreateSegmentArgs = { MissionControl: { subnet_id: toNullable() } }; - const ufoArgs: ConsoleDid.CreateSegmentArgs = {Ufo: {name: toNullable(), subnet_id: toNullable()}}; + const ufoArgs: ConsoleDid.CreateSegmentArgs = { + Ufo: { name: toNullable(), subnet_id: toNullable() } + }; beforeEach(() => { user = Ed25519KeyIdentity.generate(); @@ -263,6 +309,23 @@ describe('Console > Factory > Segment', () => { await assertMissionControl(); }); + it('should create with expected controllers', async () => { + const { get_or_init_account } = actor; + await get_or_init_account(); + + const { create_segment } = actor; + + const id = await create_segment(args); + + expect(id).not.toBeUndefined(); + + await assertController({ + user, + canisterId: id, + controllers: [user.getPrincipal(), id] + }); + }); + it('should fail with without credits and payment', async () => { const { get_or_init_account, create_segment } = actor; await get_or_init_account(); From 3da5a29c720c7dafab58961fd66b57b4380c7970 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 12:59:03 +0200 Subject: [PATCH 09/11] chore: fmt --- src/console/src/factory/ufo.rs | 4 +--- .../specs/console/upgrade/console.upgrade-v0-4-4.spec.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/console/src/factory/ufo.rs b/src/console/src/factory/ufo.rs index 828e8986ff..7741af9f39 100644 --- a/src/console/src/factory/ufo.rs +++ b/src/console/src/factory/ufo.rs @@ -3,9 +3,7 @@ use crate::constants::FREEZING_THRESHOLD_ONE_YEAR; use crate::factory::orchestrator::create_segment_with_account; use crate::factory::services::payment::{process_payment_cycles, refund_payment_cycles}; use crate::factory::types::CanisterCreator; -use crate::factory::utils::controllers::{ - remove_console_controller, -}; +use crate::factory::utils::controllers::remove_console_controller; use crate::fees::get_factory_fee; use crate::rates::increment_ufo_rate; use crate::segments::add_segment as add_segment_store; diff --git a/src/tests/specs/console/upgrade/console.upgrade-v0-4-4.spec.ts b/src/tests/specs/console/upgrade/console.upgrade-v0-4-4.spec.ts index 329d617900..f621a1914f 100644 --- a/src/tests/specs/console/upgrade/console.upgrade-v0-4-4.spec.ts +++ b/src/tests/specs/console/upgrade/console.upgrade-v0-4-4.spec.ts @@ -84,7 +84,7 @@ describe('Console > Upgrade > v0.4.3 -> v0.4.4', () => { it('should provide rates config for ufo', async () => { await upgrade(); - + const newActor = pic.createActor(idlFactoryConsole, canisterId); newActor.setIdentity(controller); From 7a77c4f725bcf109a1ab1e73fa7bd35419fdc185 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 13:05:57 +0200 Subject: [PATCH 10/11] chore: fmt --- .../console/factory/console.factory.segments.spec.ts | 4 +++- src/tests/utils/observatory-tests.utils.ts | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/tests/specs/console/factory/console.factory.segments.spec.ts b/src/tests/specs/console/factory/console.factory.segments.spec.ts index d8a0a14134..c0f815e117 100644 --- a/src/tests/specs/console/factory/console.factory.segments.spec.ts +++ b/src/tests/specs/console/factory/console.factory.segments.spec.ts @@ -1,4 +1,4 @@ -import { type ConsoleActor, type ConsoleDid } from '$declarations'; +import type { ConsoleActor, ConsoleDid } from '$declarations'; import type { Actor, PocketIc } from '@dfinity/pic'; import { fromNullable, fromNullishNullable, toNullable } from '@dfinity/utils'; import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; @@ -152,6 +152,7 @@ describe('Console > Factory > Segment', () => { describe('Assertions', () => { it('should fail with unknown account', async () => { const { create_segment } = actor; + await expect(create_segment(args({ user }))).rejects.toThrow(NO_ACCOUNT_ERROR_MSG); }); }); @@ -291,6 +292,7 @@ describe('Console > Factory > Segment', () => { describe('Assertions', () => { it('should fail with unknown account', async () => { const { create_segment } = actor; + await expect(create_segment(args)).rejects.toThrow(NO_ACCOUNT_ERROR_MSG); }); }); diff --git a/src/tests/utils/observatory-tests.utils.ts b/src/tests/utils/observatory-tests.utils.ts index 83f478b269..9fe4a2d0bf 100644 --- a/src/tests/utils/observatory-tests.utils.ts +++ b/src/tests/utils/observatory-tests.utils.ts @@ -1,8 +1,8 @@ -import { - type ObservatoryActor, - type ObservatoryActor009, - type ObservatoryDid, - type ObservatoryDid040 +import type { + ObservatoryActor, + ObservatoryActor009, + ObservatoryDid, + ObservatoryDid040 } from '$declarations'; import type { PocketIc } from '@dfinity/pic'; import { assertNonNullish, nonNullish } from '@dfinity/utils'; From ed2bc4d8e10427e143950c7dcefd51fe506104ef Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 22 Apr 2026 15:49:20 +0200 Subject: [PATCH 11/11] chore: lint --- .../specs/console/factory/console.factory.segments.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/specs/console/factory/console.factory.segments.spec.ts b/src/tests/specs/console/factory/console.factory.segments.spec.ts index c0f815e117..17fabcede7 100644 --- a/src/tests/specs/console/factory/console.factory.segments.spec.ts +++ b/src/tests/specs/console/factory/console.factory.segments.spec.ts @@ -378,7 +378,7 @@ describe('Console > Factory > Segment', () => { await get_or_init_account(); // First module works out - const firstId = await create_segment(ufoArgs); + await create_segment(ufoArgs); await pic.advanceTime(60_000); await tick(pic);