Skip to main content

o2_tools/
prop_deploy.rs

1//! Deployment helpers for the independent prop-account contract family.
2//!
3//! Prop accounts consult a stable account-oracle proxy at runtime. The oracle,
4//! registry and margin pool each retain independent SRC-14 upgrade authority.
5
6/// Test-only: the ABI selector used by session fixtures.
7#[cfg(test)]
8use crate::prop::ProlongPeriod;
9use crate::{
10    blob_loader,
11    prop::{
12        PRICE_FEED_BYTECODE,
13        PRICE_FEED_PROXY_BYTECODE,
14        PRICE_FEED_PROXY_STORAGE,
15        PRICE_FEED_STORAGE,
16        PROP_ACCOUNT_BYTECODE,
17        PROP_ACCOUNT_ORACLE_BYTECODE,
18        PROP_ACCOUNT_ORACLE_PROXY_BYTECODE,
19        PROP_ACCOUNT_ORACLE_PROXY_STORAGE,
20        PROP_ACCOUNT_ORACLE_STORAGE,
21        PROP_ACCOUNT_PROXY_BYTECODE,
22        PROP_ACCOUNT_PROXY_STORAGE,
23        PROP_ACCOUNT_STORAGE,
24        PROP_MARGIN_POOL_BYTECODE,
25        PROP_MARGIN_POOL_PROXY_BYTECODE,
26        PROP_MARGIN_POOL_PROXY_STORAGE,
27        PROP_MARGIN_POOL_STORAGE,
28        PriceFeedContract,
29        PriceFeedContractConfigurables,
30        PriceFeedProxyContract,
31        PriceFeedProxyContractConfigurables,
32        PropAccountContract,
33        PropAccountOracleContract,
34        PropAccountOracleContractConfigurables,
35        PropAccountOracleProxyContract,
36        PropAccountOracleProxyContractConfigurables,
37        PropAccountProxyContract,
38        PropAccountProxyContractConfigurables,
39        PropMarginPoolContract,
40        PropMarginPoolContractConfigurables,
41        PropMarginPoolProxyContract,
42        PropMarginPoolProxyContractConfigurables,
43        State,
44    },
45    trade_account_registry::{
46        State as TradeAccountRegistryState,
47        TRADE_ACCOUNT_REGISTER_BYTECODE,
48        TRADE_ACCOUNT_REGISTER_PROXY_BYTECODE,
49        TRADE_ACCOUNT_REGISTER_PROXY_STORAGE,
50        TRADE_ACCOUNT_REGISTER_STORAGE,
51        TradeAccountRegistry,
52        TradeAccountRegistryConfigurables,
53        TradeAccountRegistryDeployConfig,
54        TradeAccountRegistryManager,
55        TradeAccountRegistryProxy,
56        TradeAccountRegistryProxyConfigurables,
57    },
58};
59use anyhow::{
60    Context,
61    Result,
62    ensure,
63};
64use fuels::{
65    core::{
66        Configurable,
67        Configurables,
68    },
69    prelude::*,
70    programs::contract::Regular,
71    tx::StorageSlot,
72    types::{
73        Address,
74        AssetId,
75        ContractId,
76        Identity,
77        transaction_builders::Blob,
78    },
79};
80
81/// An already-deployed shared trade-account registry to reuse for
82/// prop-account registration instead of deploying a dedicated one. The
83/// registry's implementation is UPGRADED in place (new blob, same proxy) to
84/// carry the prop configurables, so the oracle ids it was deployed with must
85/// be repeated — the fresh implementation blob embeds them again.
86#[derive(Clone, Debug)]
87pub struct ExistingRegistry {
88    /// The registry proxy id every account resolution goes through.
89    pub registry_id: ContractId,
90    /// The trade-account oracle the registry validates against.
91    pub trade_account_oracle_id: ContractId,
92    /// The trial-trade-account oracle the registry validates against.
93    pub trial_trade_account_oracle_id: ContractId,
94}
95
96/// Deployment-time settings for the prop-account system.
97#[derive(Clone, Debug)]
98pub struct PropDeployConfig {
99    /// Asset used for collateral and quote accounting by the pool.
100    pub collateral_asset: AssetId,
101    /// Decimal precision of `collateral_asset`.
102    pub collateral_decimals: u8,
103    /// Maximum number of order books in one tier version.
104    pub max_tier_books: u64,
105    /// Fee charged by `repay_base_from_collateral`, in parts per million of the
106    /// quote value of the debt closed. Same scale as the order book's taker fee,
107    /// so `100` is one basis point.
108    pub base_repay_fee_ppm: u64,
109    /// Owner of the oracle implementation, feed, registry implementation and pool.
110    /// Defaults to the deployer.
111    pub owner: Option<Identity>,
112    /// Owner of the oracle, registry and pool SRC-14 proxies. Defaults to the deployer.
113    pub proxy_owner: Option<Identity>,
114    /// Cosigner exposed by every prop account. Defaults to the deployer.
115    pub cosigner: Option<Address>,
116    /// Recipient of the platform share. Defaults to the deployer.
117    pub platform_payout: Option<Identity>,
118    /// Recipient of assets retained during forced settlement. Defaults to the deployer.
119    pub liquidator: Option<Identity>,
120    /// Deterministic salt used for all contracts in this deployment.
121    pub salt: Salt,
122    /// Maximum implementation words placed into each loader data blob.
123    pub max_words_per_blob: usize,
124    /// Reuse (and upgrade) an existing shared trade-account registry
125    /// instead of deploying a dedicated one.
126    pub existing_registry: Option<ExistingRegistry>,
127    /// Stable margin-account oracle proxy to reuse and upgrade. An explicit
128    /// ID takes precedence over the registry's live oracle, allowing repairs.
129    /// If absent, reuse the registry's nonzero oracle before deriving a new one.
130    pub existing_oracle: Option<ContractId>,
131    /// Reuse an already-deployed price feed. When omitted, an initialized
132    /// existing oracle supplies its pool's feed; otherwise a production feed
133    /// is deployed behind its own SRC-14 proxy.
134    pub existing_price_feed: Option<ContractId>,
135    /// Submission-age bound written to the feed, in seconds. `None` leaves
136    /// the contract's own default in place; a value is reconciled
137    /// compare-first, so a feed already on it costs no transaction.
138    pub max_offchain_age_seconds: Option<u64>,
139    /// Reuse an already-deployed margin pool instead of deriving its
140    /// address. The derivation depends on the pool BYTECODE (through the
141    /// bootstrap blob the proxy is seeded with), so a release that changes
142    /// the pool derives a new address and forks the pool. Anyone who
143    /// already knows their pool must pin it here to get an upgrade rather
144    /// than a fork.
145    pub existing_pool: Option<ContractId>,
146}
147
148impl PropDeployConfig {
149    pub fn new(collateral_asset: AssetId) -> Self {
150        Self {
151            collateral_asset,
152            collateral_decimals: 6,
153            max_tier_books: 80,
154            base_repay_fee_ppm: 100,
155            owner: None,
156            proxy_owner: None,
157            cosigner: None,
158            platform_payout: None,
159            liquidator: None,
160            salt: Salt::default(),
161            max_words_per_blob: 10_000,
162            existing_registry: None,
163            existing_oracle: None,
164            existing_price_feed: None,
165            max_offchain_age_seconds: None,
166            existing_pool: None,
167        }
168    }
169}
170
171/// Validated existing margin-account oracle and its immutable pool binding.
172#[derive(Clone, Copy, Debug)]
173pub struct ExistingPropOracle {
174    pub oracle_id: ContractId,
175    /// `None` while the oracle is still uninitialized after an interrupted deploy.
176    pub pool_id: Option<ContractId>,
177}
178
179/// Resolve and validate the oracle before any deployment transactions.
180///
181/// A configured ID wins over the registry so a bad registry target can be
182/// repaired. Missing contracts, read failures and conflicting pool bindings
183/// are errors, never reasons to derive a replacement oracle.
184pub async fn resolve_existing_oracle<W: Account + Clone>(
185    wallet: &W,
186    configured_oracle: Option<ContractId>,
187    registry_id: Option<ContractId>,
188    configured_pool: Option<ContractId>,
189) -> Result<Option<ExistingPropOracle>> {
190    let oracle_id = if let Some(oracle_id) = configured_oracle {
191        ensure!(
192            oracle_id != ContractId::zeroed(),
193            "configured margin oracle cannot be zero"
194        );
195        oracle_id
196    } else if let Some(registry_id) = registry_id {
197        let oracle_id = TradeAccountRegistry::new(registry_id, wallet.clone())
198            .methods()
199            .get_prop_oracle_id()
200            .simulate(Execution::state_read_only())
201            .await
202            .context("read the registry's margin oracle")?
203            .value;
204        if oracle_id == ContractId::zeroed() {
205            return Ok(None);
206        }
207        oracle_id
208    } else {
209        return Ok(None);
210    };
211    ensure!(
212        wallet.try_provider()?.contract_exists(&oracle_id).await?,
213        "configured margin oracle {oracle_id} does not exist"
214    );
215    let target = PropAccountOracleProxyContract::new(oracle_id, wallet.clone())
216        .methods()
217        .proxy_target()
218        .simulate(Execution::state_read_only())
219        .await
220        .context("read the existing margin oracle proxy target")?
221        .value;
222    let oracle = PropAccountOracleContract::new(oracle_id, wallet.clone());
223    // A Create may have succeeded before either initialization transaction.
224    // Do not call through a proxy whose target has not been initialized yet.
225    let pool_id = if target.is_some()
226        && oracle
227            .methods()
228            .owner()
229            .simulate(Execution::state_read_only())
230            .await
231            .context("read the existing margin oracle owner")?
232            .value
233            != State::Uninitialized
234    {
235        let pool_id = oracle
236            .methods()
237            .get_prop_margin_pool()
238            .simulate(Execution::state_read_only())
239            .await
240            .context("read the existing margin oracle's pool")?
241            .value
242            .context("initialized margin oracle has no pool")?;
243        ensure!(
244            pool_id != ContractId::zeroed(),
245            "initialized margin oracle has a zero pool"
246        );
247        if let Some(configured_pool) = configured_pool {
248            ensure!(
249                configured_pool == pool_id,
250                "margin oracle {oracle_id} is bound to pool {pool_id}, but configured pool is {configured_pool}; the oracle's pool cannot be changed"
251            );
252        }
253        ensure!(
254            wallet.try_provider()?.contract_exists(&pool_id).await?,
255            "margin oracle's pool {pool_id} does not exist"
256        );
257        Some(pool_id)
258    } else {
259        None
260    };
261    Ok(Some(ExistingPropOracle { oracle_id, pool_id }))
262}
263
264/// Handles and implementation blob IDs produced by a complete deployment.
265#[derive(Clone)]
266pub struct PropDeployment<W> {
267    /// Oracle implementation ABI bound to its stable SRC-14 proxy.
268    pub oracle: PropAccountOracleContract<W>,
269    pub oracle_proxy: PropAccountOracleProxyContract<W>,
270    pub oracle_id: ContractId,
271    pub oracle_blob_id: BlobId,
272    /// The price feed the pool values against, bound through its proxy.
273    pub price_feed: PriceFeedContract<W>,
274    pub price_feed_id: ContractId,
275    /// Shared trade-account registry implementation ABI bound to its SRC-14 proxy.
276    pub registry: TradeAccountRegistry<W>,
277    pub registry_proxy: TradeAccountRegistryProxy<W>,
278    pub registry_id: ContractId,
279    pub registry_blob_id: BlobId,
280    /// Margin-pool implementation ABI bound to its SRC-14 proxy.
281    pub pool: PropMarginPoolContract<W>,
282    pub pool_proxy: PropMarginPoolProxyContract<W>,
283    pub pool_id: ContractId,
284    pub pool_blob_id: BlobId,
285    /// Runtime account implementation selected through the oracle proxy.
286    pub account_blob_id: BlobId,
287    /// Raw configurable account-proxy bytecode consumed by the registry.
288    pub account_proxy_blob_id: BlobId,
289    /// Fixed salt used with parent/index configurables for deterministic children.
290    pub account_salt: Salt,
291    pub deployer_wallet: W,
292}
293
294impl<W> PropDeployment<W>
295where
296    W: Account + Clone,
297{
298    /// Deploy the full system in dependency order.
299    ///
300    /// The registry is deployed before the pool so its ID can be embedded in
301    /// the pool implementation configurables. The pool ID is then stored in
302    /// the account oracle through its stable proxy.
303    pub async fn deploy(deployer_wallet: &W, config: &PropDeployConfig) -> Result<Self> {
304        ensure!(
305            config.collateral_asset != AssetId::zeroed(),
306            "prop collateral asset cannot be zero"
307        );
308        ensure!(
309            config.collateral_decimals <= 18,
310            "prop collateral decimals cannot exceed 18"
311        );
312        ensure!(
313            config.max_tier_books != 0,
314            "prop max tier books cannot be zero"
315        );
316        ensure!(
317            config.max_words_per_blob != 0,
318            "prop loader blob size cannot be zero"
319        );
320
321        let deployer = Identity::Address(deployer_wallet.address());
322        let proxy_owner = config.proxy_owner.unwrap_or(deployer);
323        let mut existing_oracle = resolve_existing_oracle(
324            deployer_wallet,
325            config.existing_oracle,
326            config
327                .existing_registry
328                .as_ref()
329                .map(|registry| registry.registry_id),
330            config.existing_pool,
331        )
332        .await?;
333        // Predict a fresh proxy without uploading anything. A deterministic
334        // retry may find an existing oracle even without saved IDs, and its
335        // immutable pool binding needs the same validation as a pinned oracle.
336        let derived_oracle = if existing_oracle.is_none() {
337            let (data_blobs, bootstrap) = blob_loader::build_loader_blobs(
338                PROP_ACCOUNT_ORACLE_BYTECODE.to_vec(),
339                config.salt,
340                storage_slots(PROP_ACCOUNT_ORACLE_STORAGE)?,
341                Configurables::default(),
342                config.max_words_per_blob,
343            )?;
344            // The bootstrap breaks the oracle -> pool -> registry -> oracle
345            // deployment cycle without making the proxy ID depend on the pool.
346            let contract = regular_contract_with_implementation_storage(
347                PROP_ACCOUNT_ORACLE_PROXY_BYTECODE,
348                PROP_ACCOUNT_ORACLE_PROXY_STORAGE,
349                PROP_ACCOUNT_ORACLE_STORAGE,
350                config.salt,
351            )?
352            .with_configurables(
353                PropAccountOracleProxyContractConfigurables::default()
354                    .with_INITIAL_OWNER(State::Initialized(proxy_owner))?
355                    .with_INITIAL_TARGET(bootstrap.id().into())?,
356            );
357            let oracle_id = contract.contract_id();
358            if deployer_wallet
359                .try_provider()?
360                .contract_exists(&oracle_id)
361                .await?
362            {
363                existing_oracle = resolve_existing_oracle(
364                    deployer_wallet,
365                    Some(oracle_id),
366                    None,
367                    config.existing_pool,
368                )
369                .await?;
370            }
371            Some((contract, data_blobs, bootstrap))
372        } else {
373            None
374        };
375        let mut resolved_config = config.clone();
376        if let Some(existing) = existing_oracle {
377            resolved_config.existing_oracle = Some(existing.oracle_id);
378            resolved_config.existing_pool = config.existing_pool.or(existing.pool_id);
379            // An initialized oracle already identifies its pool and feed.
380            // Preserve both when omitted, including across salt/bytecode changes.
381            if let Some(pool_id) = existing.pool_id
382                && config.existing_price_feed.is_none()
383            {
384                let feed_id =
385                    PropMarginPoolContract::new(pool_id, deployer_wallet.clone())
386                        .methods()
387                        .price_feed()
388                        .simulate(Execution::state_read_only())
389                        .await
390                        .context("read the existing margin pool's price feed")?
391                        .value;
392                if feed_id != ContractId::zeroed() {
393                    resolved_config.existing_price_feed = Some(feed_id);
394                }
395            }
396        }
397        let config = &resolved_config;
398
399        let owner = config.owner.unwrap_or(deployer);
400        // `initialize` refuses a zero cosigner or liquidator, so the blob this
401        // run uploads has to name SOMETHING - the deployer is the only sensible
402        // bootstrap. In production both arrive as explicit deploy parameters.
403        //
404        // The seed is only ever CONSUMED by `initialize`, which is guarded by
405        // the initialization flag, so on an already-deployed pool or oracle it
406        // is inert: storage keeps the live value and the reconciliation in
407        // `o2-deploy` is what changes it, on request. That is why the warnings
408        // are not raised here - the fallback is not news, USING it is. Warning
409        // at the fallback made an ordinary upgrade announce that the deploy key
410        // had become the residue payee, which was never true and is exactly the
411        // sort of false alarm that gets real ones ignored.
412        let cosigner_defaulted = config.cosigner.is_none();
413        let cosigner = config.cosigner.unwrap_or_else(|| deployer_wallet.address());
414        let platform_payout = config.platform_payout.unwrap_or(deployer);
415        let liquidator_defaulted = config.liquidator.is_none();
416        let liquidator = config.liquidator.unwrap_or(deployer);
417
418        let account_blob_id = upload_implementation(
419            deployer_wallet,
420            PROP_ACCOUNT_BYTECODE,
421            PROP_ACCOUNT_STORAGE,
422            Configurables::default(),
423            config,
424        )
425        .await
426        .context("deploy prop-account implementation blob")?;
427
428        // This must remain raw bytecode: the registry BLDd-loads it and patches
429        // the account-specific configurables before checking the child root.
430        let account_proxy_blob_id = blob_loader::upload_loader_blobs(
431            deployer_wallet,
432            vec![],
433            Blob::new(PROP_ACCOUNT_PROXY_BYTECODE.to_vec()),
434        )
435        .await
436        .context("deploy raw prop-account proxy blob")?;
437
438        let oracle_id = match config.existing_oracle {
439            Some(oracle_id) => oracle_id,
440            None => {
441                let (oracle_proxy_contract, data_blobs, bootstrap) = derived_oracle
442                    .context("missing fresh oracle deployment artifacts")?;
443                blob_loader::upload_loader_blobs(deployer_wallet, data_blobs, bootstrap)
444                    .await
445                    .context("deploy prop-account oracle bootstrap blobs")?;
446                let (oracle_id, _) =
447                    deploy_regular(deployer_wallet, oracle_proxy_contract)
448                        .await
449                        .context("deploy prop-account oracle proxy")?;
450                oracle_id
451            }
452        };
453        let oracle_proxy =
454            PropAccountOracleProxyContract::new(oracle_id, deployer_wallet.clone());
455        let oracle = PropAccountOracleContract::new(oracle_id, deployer_wallet.clone());
456        // State-checked rather than is-new-gated: a run that crashed between
457        // the Create and this call resumes here instead of stranding an
458        // uninitialized proxy forever.
459        let oracle_proxy_target = oracle_proxy
460            .methods()
461            .proxy_target()
462            .simulate(Execution::state_read_only())
463            .await
464            .context("read prop-account oracle proxy target")?
465            .value;
466        if oracle_proxy_target.is_none() {
467            oracle_proxy
468                .methods()
469                .initialize_proxy()
470                .call()
471                .await
472                .context("initialize prop-account oracle proxy")?;
473        }
474
475        // The PRODUCTION price feed, behind its own SRC-14 proxy — not the
476        // test mock, which has neither role administration nor a pause
477        // switch and has no business backing a real pool.
478        let (price_feed, price_feed_id) = match config.existing_price_feed {
479            Some(price_feed_id) => (
480                PriceFeedContract::new(price_feed_id, deployer_wallet.clone()),
481                price_feed_id,
482            ),
483            None => {
484                // Same bootstrap trick the pool and the oracle use: the
485                // proxy's ADDRESS must not depend on the implementation's
486                // configurables, or naming a different owner would derive a
487                // different proxy and fork the feed instead of upgrading it.
488                let feed_bootstrap_blob_id = upload_implementation(
489                    deployer_wallet,
490                    PRICE_FEED_BYTECODE,
491                    PRICE_FEED_STORAGE,
492                    Configurables::default(),
493                    config,
494                )
495                .await
496                .context("deploy price-feed bootstrap implementation blob")?;
497                let feed_proxy_configurables =
498                    PriceFeedProxyContractConfigurables::default()
499                        .with_INITIAL_OWNER(State::Initialized(proxy_owner))?
500                        .with_INITIAL_TARGET(ContractId::from(feed_bootstrap_blob_id))?;
501                let feed_proxy_contract = regular_contract_with_implementation_storage(
502                    PRICE_FEED_PROXY_BYTECODE,
503                    PRICE_FEED_PROXY_STORAGE,
504                    PRICE_FEED_STORAGE,
505                    config.salt,
506                )?
507                .with_configurables(feed_proxy_configurables);
508                let (price_feed_id, _) =
509                    deploy_regular(deployer_wallet, feed_proxy_contract)
510                        .await
511                        .context("deploy price-feed proxy")?;
512                let feed_proxy =
513                    PriceFeedProxyContract::new(price_feed_id, deployer_wallet.clone());
514                let feed_proxy_target = feed_proxy
515                    .methods()
516                    .proxy_target()
517                    .simulate(Execution::state_read_only())
518                    .await
519                    .context("read price-feed proxy target")?
520                    .value;
521                if feed_proxy_target.is_none() {
522                    feed_proxy
523                        .methods()
524                        .initialize_proxy()
525                        .call()
526                        .await
527                        .context("initialize price-feed proxy")?;
528                }
529                upgrade_price_feed_implementation(
530                    deployer_wallet,
531                    price_feed_id,
532                    owner,
533                    config,
534                )
535                .await?;
536                let price_feed =
537                    PriceFeedContract::new(price_feed_id, deployer_wallet.clone());
538                let feed_is_uninitialized = price_feed
539                    .methods()
540                    .owner()
541                    .simulate(Execution::state_read_only())
542                    .await
543                    .context("read price-feed initialization state")?
544                    .value
545                    == State::Uninitialized;
546                if feed_is_uninitialized {
547                    price_feed
548                        .methods()
549                        .initialize()
550                        .call()
551                        .await
552                        .context("initialize price-feed")?;
553                }
554                // `initialize` grants the owner the ADMIN role only —
555                // publishing needs the submitter role named explicitly, and
556                // the deploy seeds its prices as the owner. No widening: the
557                // owner may grant it to itself at will, so withholding it
558                // would only mean the first publish reverts.
559                let owner_can_publish = price_feed
560                    .methods()
561                    .has_role(PRICE_SUBMITTER_ROLE, owner)
562                    .simulate(Execution::state_read_only())
563                    .await
564                    .context("read the price feed's submitter role")?
565                    .value;
566                if !owner_can_publish {
567                    price_feed
568                        .methods()
569                        .add_publisher(owner)
570                        .call()
571                        .await
572                        .context("grant the price feed's owner the submitter role")?;
573                }
574                (price_feed, price_feed_id)
575            }
576        };
577
578        // The submission-age bound, reconciled compare-first against
579        // whatever the feed currently enforces. Opt-in: with nothing
580        // configured the contract's own default stands, so a feed this
581        // deploy does not own is never written to.
582        if let Some(seconds) = config.max_offchain_age_seconds {
583            let current = price_feed
584                .methods()
585                .get_max_offchain_age()
586                .simulate(Execution::state_read_only())
587                .await
588                .context("read the price feed's submission-age bound")?
589                .value;
590            if current != seconds {
591                price_feed
592                    .methods()
593                    .set_max_offchain_age(seconds)
594                    .call()
595                    .await
596                    .context("set the price feed's submission-age bound")?;
597            }
598        }
599
600        let [oracle_offset, parent_offset, index_offset] = prop_account_proxy_offsets()?;
601        let prop_registry_configurables = |base: TradeAccountRegistryConfigurables| {
602            Ok::<_, anyhow::Error>(
603                base.with_PROP_ACCOUNT_ORACLE_CONTRACT_ID(oracle_id)?
604                    .with_DEFAULT_PROP_ACCOUNT_PROXY(ContractId::from(
605                        account_proxy_blob_id,
606                    ))?
607                    .with_PROP_ORACLE_CONFIG_OFFSET(oracle_offset)?
608                    .with_PROP_PARENT_CONFIG_OFFSET(parent_offset)?
609                    .with_PROP_INDEX_CONFIG_OFFSET(index_offset)?,
610            )
611        };
612        let (registry_id, registry_blob_id) = match &config.existing_registry {
613            Some(existing) => {
614                // Upgrade the shared registry's implementation in place:
615                // same proxy, a fresh blob repeating the oracle ids it was
616                // deployed with plus the prop configurables above. Storage
617                // (registered accounts, ownership) rides through untouched.
618                let manager = TradeAccountRegistryManager::new(
619                    deployer_wallet.clone(),
620                    existing.registry_id,
621                );
622                let deploy_config = TradeAccountRegistryDeployConfig {
623                    registry_config: prop_registry_configurables(
624                        TradeAccountRegistryConfigurables::default(),
625                    )?,
626                    ..Default::default()
627                };
628                // Blob uploads are exists-checked; the retarget only fires
629                // when the proxy does not already point at the upgraded
630                // implementation, so a re-run is a no-op.
631                let registry_blob_id = TradeAccountRegistryManager::deploy_register_blob(
632                    deployer_wallet,
633                    existing.trade_account_oracle_id,
634                    existing.trial_trade_account_oracle_id,
635                    &deploy_config,
636                )
637                .await
638                .context("build upgraded shared trade-account registry blob")?;
639                let current_target = manager
640                    .registry_proxy
641                    .methods()
642                    .proxy_target()
643                    .simulate(Execution::state_read_only())
644                    .await
645                    .context("read shared trade-account registry proxy target")?
646                    .value;
647                if current_target != Some(ContractId::from(registry_blob_id)) {
648                    manager
649                        .registry_proxy
650                        .methods()
651                        .set_proxy_target(ContractId::from(registry_blob_id))
652                        .call()
653                        .await
654                        .context("upgrade shared trade-account registry for prop")?;
655                }
656                (existing.registry_id, registry_blob_id)
657            }
658            None => {
659                let registry_configurables = prop_registry_configurables(
660                    TradeAccountRegistryConfigurables::default()
661                        .with_INITIAL_OWNER(TradeAccountRegistryState::Initialized(
662                            owner,
663                        ))?
664                        .with_INITIAL_TRIAL_TRADE_ACCOUNT_CREATOR(owner)?,
665                )?;
666                let registry_blob_id = upload_implementation(
667                    deployer_wallet,
668                    TRADE_ACCOUNT_REGISTER_BYTECODE,
669                    TRADE_ACCOUNT_REGISTER_STORAGE,
670                    registry_configurables,
671                    config,
672                )
673                .await
674                .context("deploy shared trade-account registry implementation blob")?;
675
676                let registry_proxy_configurables =
677                    TradeAccountRegistryProxyConfigurables::default()
678                        .with_INITIAL_OWNER(TradeAccountRegistryState::Initialized(
679                            proxy_owner,
680                        ))?
681                        .with_INITIAL_TARGET(ContractId::from(registry_blob_id))?;
682                let registry_proxy_contract = regular_contract(
683                    TRADE_ACCOUNT_REGISTER_PROXY_BYTECODE,
684                    TRADE_ACCOUNT_REGISTER_PROXY_STORAGE,
685                    config.salt,
686                )?
687                .with_configurables(registry_proxy_configurables);
688                let (registry_id, registry_proxy_is_new) =
689                    deploy_regular(deployer_wallet, registry_proxy_contract)
690                        .await
691                        .context("deploy shared trade-account registry proxy")?;
692                let registry_proxy =
693                    TradeAccountRegistryProxy::new(registry_id, deployer_wallet.clone());
694                let registry =
695                    TradeAccountRegistry::new(registry_id, deployer_wallet.clone());
696                if registry_proxy_is_new {
697                    registry_proxy
698                        .methods()
699                        .initialize_proxy()
700                        .call()
701                        .await
702                        .context("initialize shared trade-account registry proxy")?;
703                    registry
704                        .methods()
705                        .initialize()
706                        .call()
707                        .await
708                        .context("initialize shared trade-account registry")?;
709                }
710                (registry_id, registry_blob_id)
711            }
712        };
713        let registry_proxy =
714            TradeAccountRegistryProxy::new(registry_id, deployer_wallet.clone());
715        let registry = TradeAccountRegistry::new(registry_id, deployer_wallet.clone());
716
717        let pool_configurables = PropMarginPoolContractConfigurables::default()
718            .with_INITIAL_ADMIN(owner)?
719            .with_INITIAL_REGISTRY(registry_id)?
720            .with_INITIAL_PRICE_FEED(price_feed_id)?
721            .with_INITIAL_PLATFORM_PAYOUT(platform_payout)?
722            .with_INITIAL_LIQUIDATOR(liquidator)?
723            .with_COLLATERAL_ASSET(config.collateral_asset)?
724            .with_COLLATERAL_DECIMALS(config.collateral_decimals)?
725            .with_MAX_TIER_BOOKS(config.max_tier_books)?
726            .with_BASE_REPAY_FEE_PPM(config.base_repay_fee_ppm)?;
727        let pool_blob_id = upload_implementation(
728            deployer_wallet,
729            PROP_MARGIN_POOL_BYTECODE,
730            PROP_MARGIN_POOL_STORAGE,
731            pool_configurables,
732            config,
733        )
734        .await
735        .context("deploy prop margin-pool implementation blob")?;
736
737        // The proxy's ADDRESS must not depend on the implementation's
738        // configurables, or changing a runtime value - the liquidator, the
739        // payout party, the feed - would derive a DIFFERENT proxy and fork the
740        // pool instead of upgrading it. Point it at a default-config bootstrap
741        // blob (the same trick the oracle below uses to break its own cycle),
742        // then retarget to the configured implementation.
743        // ...but that only covers CONFIGURABLE changes. The bootstrap blob
744        // id is a hash of the pool BYTECODE, so any release that touches
745        // the pool derives a different `INITIAL_TARGET`, a different proxy
746        // address, and forks the pool instead of upgrading it — silently,
747        // leaving the funded one orphaned. A caller that already knows its
748        // pool pins the id, and derivation is only for the first deploy.
749        let pool_id = match config.existing_pool {
750            Some(pool_id) => pool_id,
751            None => {
752                let pool_bootstrap_blob_id = upload_implementation(
753                    deployer_wallet,
754                    PROP_MARGIN_POOL_BYTECODE,
755                    PROP_MARGIN_POOL_STORAGE,
756                    Configurables::default(),
757                    config,
758                )
759                .await
760                .context("deploy prop margin-pool bootstrap implementation blob")?;
761                let pool_proxy_configurables =
762                    PropMarginPoolProxyContractConfigurables::default()
763                        .with_INITIAL_OWNER(State::Initialized(proxy_owner))?
764                        .with_INITIAL_TARGET(ContractId::from(pool_bootstrap_blob_id))?;
765                let pool_proxy_contract = regular_contract_with_implementation_storage(
766                    PROP_MARGIN_POOL_PROXY_BYTECODE,
767                    PROP_MARGIN_POOL_PROXY_STORAGE,
768                    PROP_MARGIN_POOL_STORAGE,
769                    config.salt,
770                )?
771                .with_configurables(pool_proxy_configurables);
772                let (pool_id, _) = deploy_regular(deployer_wallet, pool_proxy_contract)
773                    .await
774                    .context("deploy prop margin-pool proxy")?;
775                pool_id
776            }
777        };
778        let pool_proxy =
779            PropMarginPoolProxyContract::new(pool_id, deployer_wallet.clone());
780        let pool = PropMarginPoolContract::new(pool_id, deployer_wallet.clone());
781        let pool_proxy_target = pool_proxy
782            .methods()
783            .proxy_target()
784            .simulate(Execution::state_read_only())
785            .await
786            .context("read prop margin-pool proxy target")?
787            .value;
788        if pool_proxy_target.is_none() {
789            pool_proxy
790                .methods()
791                .initialize_proxy()
792                .call()
793                .await
794                .context("initialize prop margin-pool proxy")?;
795        }
796        // The upgrade itself: new configurables mean a new blob, and the
797        // stable proxy is pointed at it. A run that changes nothing computes
798        // the same blob id and retargets nothing.
799        let pool_target = pool_proxy
800            .methods()
801            .proxy_target()
802            .simulate(Execution::state_read_only())
803            .await
804            .context("read configured prop margin-pool proxy target")?
805            .value;
806        if pool_target != Some(ContractId::from(pool_blob_id)) {
807            pool_proxy
808                .methods()
809                .set_proxy_target(ContractId::from(pool_blob_id))
810                .call()
811                .await
812                .context("activate configured prop margin-pool implementation")?;
813        }
814
815        let oracle_configurables = PropAccountOracleContractConfigurables::default()
816            .with_INITIAL_OWNER(owner)?
817            .with_INITIAL_PROP_ACCOUNT_IMPL(ContractId::from(account_blob_id))?
818            .with_INITIAL_COSIGNER(cosigner)?
819            .with_INITIAL_PROP_MARGIN_POOL(pool_id)?;
820        let oracle_blob_id = upload_implementation(
821            deployer_wallet,
822            PROP_ACCOUNT_ORACLE_BYTECODE,
823            PROP_ACCOUNT_ORACLE_STORAGE,
824            oracle_configurables,
825            config,
826        )
827        .await
828        .context("deploy configured prop-account oracle implementation blob")?;
829        let oracle_target = oracle_proxy
830            .methods()
831            .proxy_target()
832            .simulate(Execution::state_read_only())
833            .await
834            .context("read prop-account oracle proxy target")?
835            .value;
836        if oracle_target != Some(ContractId::from(oracle_blob_id)) {
837            oracle_proxy
838                .methods()
839                .set_proxy_target(ContractId::from(oracle_blob_id))
840                .call()
841                .await
842                .context("activate configured prop-account oracle implementation")?;
843        }
844
845        let oracle_is_uninitialized = oracle
846            .methods()
847            .owner()
848            .simulate(Execution::state_read_only())
849            .await
850            .context("read prop-account oracle initialization state")?
851            .value
852            == State::Uninitialized;
853        if oracle_is_uninitialized {
854            // NOW the seed becomes the live cosigner, so now it is worth
855            // saying. An oracle that was already initialized keeps whatever
856            // its storage holds and never reaches this branch.
857            if cosigner_defaulted {
858                tracing::warn!(
859                    "prop deploy: no cosigner given and this oracle is being \
860                     initialized - the deployer becomes the cosigner. The \
861                     backend must run MARGIN_COSIGNER_KEY for this address or \
862                     margin stays inert."
863                );
864            }
865            oracle
866                .methods()
867                .initialize()
868                .call()
869                .await
870                .context("initialize prop-account oracle")?;
871        }
872
873        // The account implementation EVERY margin account resolves through,
874        // reconciled explicitly — the same defect the cosigner already had,
875        // and the price feed had on the pool.
876        //
877        // `INITIAL_PROP_ACCOUNT_IMPL` reaches storage only through the
878        // one-time `initialize` above. On an already-initialized oracle that
879        // branch is skipped, so baking a fresh `account_blob_id` into the
880        // oracle's configurables and retargeting its proxy — both of which
881        // this run does, and both of which succeed — still leaves
882        // `get_prop_account_impl` answering with the PREVIOUS blob. Every
883        // account keeps running the old bytecode while the deploy reports a
884        // clean upgrade, which is the worst shape a bug can take.
885        //
886        // Compare-first, like every other reconcile here: an oracle already
887        // on this implementation costs one read and no transaction.
888        let stored_impl = oracle
889            .methods()
890            .get_prop_account_impl()
891            .simulate(Execution::state_read_only())
892            .await
893            .context("read the prop-account oracle's account implementation")?
894            .value;
895        let account_impl = ContractId::from(account_blob_id);
896        if stored_impl != Some(account_impl) {
897            tracing::info!(
898                "Prop: account implementation {stored_impl:?} -> {account_impl:?}"
899            );
900            oracle
901                .methods()
902                .set_prop_account_impl(account_impl)
903                .call()
904                .await
905                .context("activate the prop-account implementation on the oracle")?;
906        }
907
908        // The account implementation EVERY margin account resolves through,
909        // reconciled explicitly — the same defect the cosigner already had,
910        // and the price feed had on the pool.
911        //
912        // `INITIAL_PROP_ACCOUNT_IMPL` reaches storage only through the
913        // one-time `initialize` above. On an already-initialized oracle that
914        // branch is skipped, so baking a fresh `account_blob_id` into the
915        // oracle's configurables and retargeting its proxy — both of which
916        // this run does, and both of which succeed — still leaves
917        // `get_prop_account_impl` answering with the PREVIOUS blob. Every
918        // account keeps running the old bytecode while the deploy reports a
919        // clean upgrade, which is the worst shape a bug can take.
920        //
921        // Compare-first, like every other reconcile here: an oracle already
922        // on this implementation costs one read and no transaction.
923
924        // State-checked: a resumed run initializes iff the pool has not
925        // been initialized yet, whether or not this run created the proxy.
926        let deployed_collateral = pool
927            .methods()
928            .collateral_asset()
929            .simulate(Execution::state_read_only())
930            .await
931            .context("read deployed prop margin-pool collateral")?
932            .value;
933        ensure!(
934            deployed_collateral == config.collateral_asset,
935            "prop margin-pool collateral configurable mismatch"
936        );
937        ensure!(
938            pool.methods()
939                .collateral_decimals()
940                .simulate(Execution::state_read_only())
941                .await
942                .context("read deployed prop margin-pool collateral decimals")?
943                .value
944                == config.collateral_decimals,
945            "prop margin-pool decimals configurable mismatch"
946        );
947        ensure!(
948            pool.methods()
949                .max_tier_books()
950                .simulate(Execution::state_read_only())
951                .await
952                .context("read deployed prop margin-pool book limit")?
953                .value
954                == config.max_tier_books,
955            "prop margin-pool book-limit configurable mismatch"
956        );
957        let pool_is_initialized = pool
958            .methods()
959            .is_initialized()
960            .simulate(Execution::state_read_only())
961            .await
962            .context("read deployed prop margin-pool initialization state")?
963            .value;
964        if !pool_is_initialized {
965            // Same rule as the oracle's cosigner above: the seed only becomes
966            // the payee here, on a pool that has never been initialized.
967            if liquidator_defaulted {
968                tracing::warn!(
969                    "prop deploy: no liquidator given and this pool is being \
970                     initialized - the deployer becomes the liquidator, and \
971                     will receive the residue of every forced exit."
972                );
973            }
974            pool.methods()
975                .initialize()
976                .call()
977                .await
978                .context("initialize prop margin pool")?;
979        }
980
981        // The expiry cosigner is NOT reconciled here: `o2-deploy` owns that
982        // (it also covers `--margin-tier-only`, which never reaches this
983        // function), and the fixture arms it per test.
984        Ok(Self {
985            oracle,
986            oracle_proxy,
987            oracle_id,
988            oracle_blob_id,
989            price_feed,
990            price_feed_id,
991            registry,
992            registry_proxy,
993            registry_id,
994            registry_blob_id,
995            pool,
996            pool_proxy,
997            pool_id,
998            pool_blob_id,
999            account_blob_id,
1000            account_proxy_blob_id,
1001            account_salt: config.salt,
1002            deployer_wallet: deployer_wallet.clone(),
1003        })
1004    }
1005
1006    /// Deploy and register one deterministic prop-account child.
1007    ///
1008    /// `parent_caller` submits the registration transaction and must resolve
1009    /// to `parent`; the registry enforces this relationship on chain.
1010    pub async fn deploy_account(
1011        &self,
1012        parent_caller: &W,
1013        parent: Identity,
1014        index: u64,
1015    ) -> Result<PropAccountProxyContract<W>> {
1016        ensure!(
1017            parent != Identity::Address(Address::zeroed()),
1018            "prop-account parent cannot be zero"
1019        );
1020
1021        let configurables = PropAccountProxyContractConfigurables::default()
1022            .with_ORACLE_CONTRACT_ID(self.oracle_id)?
1023            .with_PARENT(parent)?
1024            .with_INDEX(index)?;
1025        let child_contract = regular_contract(
1026            PROP_ACCOUNT_PROXY_BYTECODE,
1027            PROP_ACCOUNT_PROXY_STORAGE,
1028            self.account_salt,
1029        )?
1030        .with_configurables(configurables);
1031        let (child_id, _) = deploy_regular(&self.deployer_wallet, child_contract)
1032            .await
1033            .context("deploy prop-account child")?;
1034
1035        let child = PropAccountProxyContract::new(child_id, self.deployer_wallet.clone());
1036        if !self
1037            .registry
1038            .methods()
1039            .prop_is_valid(child_id)
1040            .simulate(Execution::state_read_only())
1041            .await?
1042            .value
1043        {
1044            TradeAccountRegistry::new(self.registry_id, parent_caller.clone())
1045                .methods()
1046                .prop_register_contract(child_id, parent, index)
1047                .with_contract_ids(&[self.oracle_id, child_id, self.pool_id])
1048                .call()
1049                .await
1050                .context("register prop-account child")?;
1051        }
1052
1053        Ok(child)
1054    }
1055
1056    /// Bind the implementation ABI to a deployed child proxy.
1057    pub fn account(&self, account_id: ContractId) -> PropAccountContract<W> {
1058        PropAccountContract::new(account_id, self.deployer_wallet.clone())
1059    }
1060}
1061
1062/// Byte offsets of the margin-account proxy's `ORACLE_CONTRACT_ID`,
1063/// `PARENT` and `INDEX` configurables, in that order.
1064///
1065/// A build-time property of the bundled artifact — nothing touches the
1066/// chain. Public because the PLAIN registry upgrade has to repeat these
1067/// values verbatim or it resets them to zero; see
1068/// `live_prop_registry_config` in `o2-deploy`.
1069pub fn prop_account_proxy_offsets() -> Result<[u64; 3]> {
1070    fn only_offset(configurables: Configurables, name: &str) -> Result<u64> {
1071        let offsets: Vec<Configurable> = configurables.offsets_with_data;
1072        ensure!(
1073            offsets.len() == 1,
1074            "expected one {name} configurable, got {}",
1075            offsets.len()
1076        );
1077        Ok(offsets[0].offset)
1078    }
1079
1080    let oracle: Configurables = PropAccountProxyContractConfigurables::default()
1081        .with_ORACLE_CONTRACT_ID(ContractId::zeroed())?
1082        .into();
1083    let parent: Configurables = PropAccountProxyContractConfigurables::default()
1084        .with_PARENT(Identity::Address(Address::zeroed()))?
1085        .into();
1086    let index: Configurables = PropAccountProxyContractConfigurables::default()
1087        .with_INDEX(0)?
1088        .into();
1089
1090    Ok([
1091        only_offset(oracle, "oracle")?,
1092        only_offset(parent, "parent")?,
1093        only_offset(index, "index")?,
1094    ])
1095}
1096
1097fn storage_slots(bytes: &[u8]) -> Result<Vec<StorageSlot>> {
1098    serde_json::from_slice(bytes).context("decode contract storage slots")
1099}
1100
1101fn regular_contract(
1102    bytecode: &[u8],
1103    storage: &[u8],
1104    salt: Salt,
1105) -> Result<Contract<Regular>> {
1106    Ok(Contract::regular(
1107        bytecode.to_vec(),
1108        salt,
1109        storage_slots(storage)?,
1110    ))
1111}
1112
1113fn regular_contract_with_implementation_storage(
1114    bytecode: &[u8],
1115    proxy_storage: &[u8],
1116    implementation_storage: &[u8],
1117    salt: Salt,
1118) -> Result<Contract<Regular>> {
1119    let mut slots = storage_slots(proxy_storage)?;
1120    for implementation_slot in storage_slots(implementation_storage)? {
1121        ensure!(
1122            !slots
1123                .iter()
1124                .any(|proxy_slot| proxy_slot.key() == implementation_slot.key()),
1125            "proxy and implementation storage slots collide"
1126        );
1127        slots.push(implementation_slot);
1128    }
1129    Ok(Contract::regular(bytecode.to_vec(), salt, slots))
1130}
1131
1132/// Points a price-feed PROXY at the implementation this build carries,
1133/// exactly as the order books are upgraded: read the live target, rebuild
1134/// the configured blob, and retarget only when the two differ. A feed
1135/// already on this bytecode costs no transaction.
1136///
1137/// Returns the active implementation id.
1138///
1139/// `set_proxy_target` is owner-only, so this works on a feed this deployer
1140/// owns; a feed owned elsewhere must be upgraded by its own owner.
1141pub async fn upgrade_price_feed_implementation<W>(
1142    deployer_wallet: &W,
1143    price_feed_id: ContractId,
1144    owner: Identity,
1145    config: &PropDeployConfig,
1146) -> Result<ContractId>
1147where
1148    W: Account + Clone,
1149{
1150    let feed_proxy = PriceFeedProxyContract::new(price_feed_id, deployer_wallet.clone());
1151    let current = feed_proxy
1152        .methods()
1153        .proxy_target()
1154        .simulate(Execution::state_read_only())
1155        .await
1156        .context(
1157            "read the price feed's proxy target - an unproxied feed cannot be \
1158             upgraded in place",
1159        )?
1160        .value;
1161    let feed_blob_id = upload_implementation(
1162        deployer_wallet,
1163        PRICE_FEED_BYTECODE,
1164        PRICE_FEED_STORAGE,
1165        PriceFeedContractConfigurables::default()
1166            .with_INITIAL_OWNER(State::Initialized(owner))?,
1167        config,
1168    )
1169    .await
1170    .context("deploy configured price-feed implementation blob")?;
1171    let feed_blob_id = ContractId::from(feed_blob_id);
1172    if current != Some(feed_blob_id) {
1173        tracing::info!(
1174            "Upgrade price feed implementation from {current:?} to {feed_blob_id:?}"
1175        );
1176        feed_proxy
1177            .methods()
1178            .set_proxy_target(feed_blob_id)
1179            .call()
1180            .await
1181            .context("activate configured price-feed implementation")?;
1182    }
1183    Ok(feed_blob_id)
1184}
1185
1186/// `PRICE_SUBMITTER_ROLE` on the price feed — the role `add_publisher`
1187/// grants and `publish_prices` demands. Mirrors the contract's constant.
1188pub const PRICE_SUBMITTER_ROLE: u64 = 2;
1189
1190async fn upload_implementation<W>(
1191    deployer_wallet: &W,
1192    bytecode: &[u8],
1193    storage: &[u8],
1194    configurables: impl Into<Configurables>,
1195    config: &PropDeployConfig,
1196) -> Result<BlobId>
1197where
1198    W: Account,
1199{
1200    let (data_blobs, loader_blob) = blob_loader::build_loader_blobs(
1201        bytecode.to_vec(),
1202        config.salt,
1203        storage_slots(storage)?,
1204        configurables,
1205        config.max_words_per_blob,
1206    )?;
1207    blob_loader::upload_loader_blobs(deployer_wallet, data_blobs, loader_blob).await
1208}
1209
1210async fn deploy_regular<W>(
1211    deployer_wallet: &W,
1212    contract: Contract<Regular>,
1213) -> Result<(ContractId, bool)>
1214where
1215    W: Account,
1216{
1217    let contract_id = contract.contract_id();
1218    let is_new = !deployer_wallet
1219        .try_provider()?
1220        .contract_exists(&contract_id)
1221        .await?;
1222    if is_new {
1223        contract
1224            .deploy(deployer_wallet, TxPolicies::default())
1225            .await?;
1226    }
1227    Ok((contract_id, is_new))
1228}
1229
1230#[cfg(test)]
1231#[path = "prop_legacy_fixtures.rs"]
1232mod legacy_fixtures;
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237    use crate::{
1238        order_book_deploy::{
1239            OrderArgs,
1240            OrderBookConfigurables,
1241            OrderBookDeploy,
1242            OrderBookDeployConfig,
1243            OrderType,
1244        },
1245        prop::{
1246            MarginPoolPauseChanged,
1247            MarginPoolPlatformPayoutChanged,
1248            MarginPoolPriceFeedChanged,
1249            MarginPoolRegistryChanged,
1250            MarginTradeAccountWithdrawn,
1251            PriceInput,
1252            PropOrderBookCleanup,
1253            SessionClosed,
1254            SessionSeized,
1255            SettlementReason,
1256            TierParams,
1257        },
1258    };
1259    use fuels::test_helpers::{
1260        AssetConfig,
1261        WalletsConfig,
1262        launch_custom_provider_and_get_wallets,
1263    };
1264    use std::time::{
1265        SystemTime,
1266        UNIX_EPOCH,
1267    };
1268
1269    fn assert_recent_timestamp(timestamp: u64) {
1270        let now = SystemTime::now()
1271            .duration_since(UNIX_EPOCH)
1272            .expect("system time predates the Unix epoch")
1273            .as_secs();
1274        // A generous window on purpose: this catches a garbage stamp - epoch
1275        // zero, a wildly wrong clock - not sub-second precision. A tight
1276        // bound made the whole prop suite serialise behind one mutex just so
1277        // no test was ever starved long enough to blow it; the tests each run
1278        // their own node, so the mutex only ever bought this tolerance. With
1279        // the window widened the tests parallelise and the mutex is gone.
1280        assert!(
1281            timestamp.abs_diff(now) <= 120,
1282            "event timestamp {timestamp} is not close to {now}"
1283        );
1284    }
1285
1286    #[tokio::test]
1287    async fn deploys_independent_prop_system_and_registers_child() {
1288        let collateral_asset = AssetId::new([1; 32]);
1289        let base_asset = AssetId::new([2; 32]);
1290        let initial_balance = 10_000_000_000u64;
1291        let mut wallets = launch_custom_provider_and_get_wallets(
1292            WalletsConfig::new_multiple_assets(
1293                2,
1294                vec![
1295                    AssetConfig {
1296                        id: AssetId::default(),
1297                        num_coins: 2,
1298                        coin_amount: initial_balance,
1299                    },
1300                    AssetConfig {
1301                        id: collateral_asset,
1302                        num_coins: 2,
1303                        coin_amount: initial_balance,
1304                    },
1305                    AssetConfig {
1306                        id: base_asset,
1307                        num_coins: 2,
1308                        coin_amount: initial_balance,
1309                    },
1310                ],
1311            ),
1312            None,
1313            Some(::fuels::test_helpers::ChainConfig::local_testnet()),
1314        )
1315        .await
1316        .unwrap();
1317        let user = wallets.pop().unwrap();
1318        let deployer = wallets.pop().unwrap();
1319
1320        let deployment = PropDeployment::deploy(&deployer, &{
1321            // The production feed bounds a submission's age against the
1322            // block clock; this test publishes at a fixed timestamp.
1323            let mut config = PropDeployConfig::new(collateral_asset);
1324            config.max_offchain_age_seconds = Some(10_000_000_000);
1325            config
1326        })
1327        .await
1328        .unwrap();
1329
1330        let oracle_target = deployment
1331            .oracle
1332            .methods()
1333            .get_prop_account_impl()
1334            .simulate(Execution::state_read_only())
1335            .await
1336            .unwrap()
1337            .value;
1338        assert_eq!(
1339            oracle_target,
1340            Some(ContractId::from(deployment.account_blob_id))
1341        );
1342        assert_eq!(
1343            deployment
1344                .oracle
1345                .methods()
1346                .get_prop_margin_pool()
1347                .simulate(Execution::state_read_only())
1348                .await
1349                .unwrap()
1350                .value,
1351            Some(deployment.pool_id)
1352        );
1353        assert_eq!(
1354            deployment
1355                .registry
1356                .methods()
1357                .get_prop_oracle_id()
1358                .simulate(Execution::state_read_only())
1359                .await
1360                .unwrap()
1361                .value,
1362            deployment.oracle_id
1363        );
1364        assert_eq!(
1365            deployment
1366                .registry
1367                .methods()
1368                .get_prop_pool_id()
1369                .with_contract_ids(&[deployment.oracle_id])
1370                .simulate(Execution::state_read_only())
1371                .await
1372                .unwrap()
1373                .value,
1374            deployment.pool_id
1375        );
1376        assert_eq!(
1377            deployment
1378                .pool
1379                .methods()
1380                .registry()
1381                .simulate(Execution::state_read_only())
1382                .await
1383                .unwrap()
1384                .value,
1385            deployment.registry_id
1386        );
1387        assert_eq!(
1388            deployment
1389                .pool
1390                .methods()
1391                .price_feed()
1392                .simulate(Execution::state_read_only())
1393                .await
1394                .unwrap()
1395                .value,
1396            deployment.price_feed_id
1397        );
1398        let deployer_identity = Identity::Address(deployer.address());
1399
1400        // The float is simply what the pool holds: outside money arrives as a
1401        // plain transfer, with no pool entry point to call.
1402        let inventory_amount = 1_234;
1403        deployer
1404            .force_transfer_to_contract(
1405                deployment.pool.contract_id(),
1406                inventory_amount,
1407                collateral_asset,
1408                TxPolicies::default(),
1409            )
1410            .await
1411            .unwrap();
1412        assert_eq!(
1413            deployment
1414                .pool
1415                .get_balances()
1416                .await
1417                .unwrap()
1418                .get(&collateral_asset)
1419                .copied()
1420                .unwrap_or_default(),
1421            inventory_amount,
1422        );
1423
1424        let registry_update = deployment
1425            .pool
1426            .methods()
1427            .set_registry(deployment.oracle_id)
1428            .call()
1429            .await
1430            .unwrap();
1431        let registry_events = registry_update
1432            .decode_logs_with_type::<MarginPoolRegistryChanged>()
1433            .unwrap();
1434        assert_recent_timestamp(registry_events[0].timestamp.unix);
1435        assert_eq!(
1436            registry_events,
1437            vec![MarginPoolRegistryChanged {
1438                old_registry: deployment.registry_id,
1439                new_registry: deployment.oracle_id,
1440                timestamp: registry_events[0].timestamp.clone(),
1441            }]
1442        );
1443        assert_eq!(
1444            deployment
1445                .pool
1446                .methods()
1447                .registry()
1448                .simulate(Execution::state_read_only())
1449                .await
1450                .unwrap()
1451                .value,
1452            deployment.oracle_id
1453        );
1454        deployment
1455            .pool
1456            .methods()
1457            .set_registry(deployment.registry_id)
1458            .call()
1459            .await
1460            .unwrap();
1461
1462        let price_feed_update = deployment
1463            .pool
1464            .methods()
1465            .set_price_feed(deployment.oracle_id)
1466            .call()
1467            .await
1468            .unwrap();
1469        let price_feed_events = price_feed_update
1470            .decode_logs_with_type::<MarginPoolPriceFeedChanged>()
1471            .unwrap();
1472        assert_recent_timestamp(price_feed_events[0].timestamp.unix);
1473        assert_eq!(
1474            price_feed_events,
1475            vec![MarginPoolPriceFeedChanged {
1476                old_price_feed: deployment.price_feed_id,
1477                new_price_feed: deployment.oracle_id,
1478                timestamp: price_feed_events[0].timestamp.clone(),
1479            }]
1480        );
1481        assert_eq!(
1482            deployment
1483                .pool
1484                .methods()
1485                .price_feed()
1486                .simulate(Execution::state_read_only())
1487                .await
1488                .unwrap()
1489                .value,
1490            deployment.oracle_id
1491        );
1492        deployment
1493            .pool
1494            .methods()
1495            .set_price_feed(deployment.price_feed_id)
1496            .call()
1497            .await
1498            .unwrap();
1499
1500        let platform_payout = Identity::Address(user.address());
1501        let payout_update = deployment
1502            .pool
1503            .methods()
1504            .set_platform_payout(platform_payout)
1505            .call()
1506            .await
1507            .unwrap();
1508        let payout_events = payout_update
1509            .decode_logs_with_type::<MarginPoolPlatformPayoutChanged>()
1510            .unwrap();
1511        assert_recent_timestamp(payout_events[0].timestamp.unix);
1512        assert_eq!(
1513            payout_events,
1514            vec![MarginPoolPlatformPayoutChanged {
1515                old_platform_payout: deployer_identity,
1516                new_platform_payout: platform_payout,
1517                timestamp: payout_events[0].timestamp.clone(),
1518            }]
1519        );
1520        deployment
1521            .pool
1522            .methods()
1523            .set_platform_payout(deployer_identity)
1524            .call()
1525            .await
1526            .unwrap();
1527
1528        let pause_response = deployment.pool.methods().pause().call().await.unwrap();
1529        let pause_events = pause_response
1530            .decode_logs_with_type::<MarginPoolPauseChanged>()
1531            .unwrap();
1532        assert_recent_timestamp(pause_events[0].timestamp.unix);
1533        assert_eq!(pause_events.len(), 1);
1534        assert!(pause_events[0].paused);
1535
1536        let unpause_response = deployment.pool.methods().unpause().call().await.unwrap();
1537        let unpause_events = unpause_response
1538            .decode_logs_with_type::<MarginPoolPauseChanged>()
1539            .unwrap();
1540        assert_recent_timestamp(unpause_events[0].timestamp.unix);
1541        assert_eq!(unpause_events.len(), 1);
1542        assert!(!unpause_events[0].paused);
1543
1544        let user_pool = PropMarginPoolContract::new(deployment.pool_id, user.clone());
1545        assert!(
1546            user_pool
1547                .methods()
1548                .set_registry(deployment.oracle_id)
1549                .call()
1550                .await
1551                .is_err()
1552        );
1553
1554        assert!(
1555            deployment
1556                .pool
1557                .methods()
1558                .has_role(0, deployer_identity)
1559                .simulate(Execution::state_read_only())
1560                .await
1561                .unwrap()
1562                .value
1563        );
1564        let order_book_configurables = OrderBookConfigurables::default()
1565            .with_MAKER_FEE(0u64.into())
1566            .unwrap()
1567            .with_TAKER_FEE(0u64.into())
1568            .unwrap()
1569            .with_MIN_ORDER(1)
1570            .unwrap()
1571            .with_DUST(0)
1572            .unwrap();
1573        let order_book_config =
1574            OrderBookDeployConfig::with_configurables(order_book_configurables);
1575        let order_book = OrderBookDeploy::deploy(
1576            &deployer,
1577            base_asset,
1578            collateral_asset,
1579            &order_book_config,
1580        )
1581        .await
1582        .unwrap();
1583        let mut second_order_book_config = order_book_config.clone();
1584        second_order_book_config.salt = Salt::from([1u8; 32]);
1585        let second_order_book = OrderBookDeploy::deploy(
1586            &deployer,
1587            base_asset,
1588            collateral_asset,
1589            &second_order_book_config,
1590        )
1591        .await
1592        .unwrap();
1593
1594        deployment
1595            .price_feed
1596            .methods()
1597            .set_asset_decimals(collateral_asset, 6)
1598            .call()
1599            .await
1600            .unwrap();
1601        deployment
1602            .price_feed
1603            .methods()
1604            .set_asset_decimals(base_asset, 9)
1605            .call()
1606            .await
1607            .unwrap();
1608        deployment
1609            .price_feed
1610            .methods()
1611            .publish_prices(vec![
1612                PriceInput {
1613                    asset: collateral_asset,
1614                    bid: 1_000_000_000_000_000_000u64.into(),
1615                    ask: 1_000_000_000_000_000_000u64.into(),
1616                    // Strictly above the replay floor of 0, and in the past.
1617                    timestamp: 1,
1618                },
1619                PriceInput {
1620                    asset: base_asset,
1621                    bid: 2_000_000_000_000_000_000u64.into(),
1622                    ask: 2_000_000_000_000_000_000u64.into(),
1623                    timestamp: 1,
1624                },
1625            ])
1626            .call()
1627            .await
1628            .unwrap();
1629        assert!(
1630            deployment
1631                .price_feed
1632                .methods()
1633                .has_price(collateral_asset)
1634                .simulate(Execution::state_read_only())
1635                .await
1636                .unwrap()
1637                .value
1638        );
1639
1640        let tier_params = TierParams {
1641            line: 10_000_000,
1642            leverage: 5,
1643            duration: 86_400,
1644            maintenance_bps: 250,
1645            open_buffer_bps: 375,
1646            liq_price_factor: 9_900,
1647            // The amounts the old bps rates derived from this line: 6, 21,
1648            // 76 and 738 bps of 10_000_000.
1649            prolong_fee: [0, 21_000, 76_000, 738_000],
1650            max_credit_line_bps: 20_000,
1651            max_price_age: 60,
1652            open_fee: 0,
1653            profit_share_bps: 1_000,
1654            price_band_bps: 1_000,
1655        };
1656        // Historical refundable tiers remain serviceable, but cannot be published.
1657        let error = deployment
1658            .pool
1659            .methods()
1660            .publish_tier_version(1, tier_params.clone(), vec![order_book.contract_id])
1661            .call()
1662            .await
1663            .unwrap_err();
1664        assert!(error.to_string().contains("Disabled"), "{error:#}");
1665        legacy_fixtures::seed_legacy_tier(
1666            &deployment,
1667            1,
1668            tier_params,
1669            vec![order_book.contract_id, second_order_book.contract_id],
1670        )
1671        .await
1672        .unwrap();
1673
1674        let tier = deployment
1675            .pool
1676            .methods()
1677            .get_tier(1, 1)
1678            .simulate(Execution::state_read_only())
1679            .await
1680            .unwrap()
1681            .value
1682            .expect("seeded historical tier version");
1683        assert_eq!(tier.line, 10_000_000);
1684        assert_eq!(
1685            deployment
1686                .pool
1687                .methods()
1688                .current_tier_version(1)
1689                .simulate(Execution::state_read_only())
1690                .await
1691                .unwrap()
1692                .value,
1693            Some(1)
1694        );
1695        assert_eq!(
1696            deployment
1697                .pool
1698                .methods()
1699                .tier_books(1, 1)
1700                .simulate(Execution::state_read_only())
1701                .await
1702                .unwrap()
1703                .value,
1704            vec![order_book.contract_id, second_order_book.contract_id]
1705        );
1706        let tier_assets = deployment
1707            .pool
1708            .methods()
1709            .tier_assets(1, 1)
1710            .simulate(Execution::state_read_only())
1711            .await
1712            .unwrap()
1713            .value;
1714        assert_eq!(tier_assets.len(), 2);
1715        assert!(tier_assets.contains(&base_asset));
1716        assert!(tier_assets.contains(&collateral_asset));
1717        assert!(
1718            deployment
1719                .pool
1720                .methods()
1721                .get_tier(1, 2)
1722                .simulate(Execution::state_read_only())
1723                .await
1724                .unwrap()
1725                .value
1726                .is_none()
1727        );
1728        assert!(
1729            deployment
1730                .pool
1731                .methods()
1732                .tier_books(1, 2)
1733                .simulate(Execution::state_read_only())
1734                .await
1735                .is_err()
1736        );
1737        assert!(
1738            deployment
1739                .pool
1740                .methods()
1741                .tier_assets(1, 2)
1742                .simulate(Execution::state_read_only())
1743                .await
1744                .is_err()
1745        );
1746
1747        deployment
1748            .price_feed
1749            .methods()
1750            .set_asset_decimals(base_asset, 8)
1751            .call()
1752            .await
1753            .unwrap();
1754        deployment
1755            .price_feed
1756            .methods()
1757            .publish_prices(vec![PriceInput {
1758                // Above this asset's replay floor, which the publish above
1759                // already moved to 1.
1760                asset: base_asset,
1761                bid: 2_000_000_000_000_000_000u64.into(),
1762                ask: 2_000_000_000_000_000_000u64.into(),
1763                timestamp: 2,
1764            }])
1765            .call()
1766            .await
1767            .unwrap();
1768        assert!(
1769            deployment
1770                .pool
1771                .methods()
1772                .set_price_feed(deployment.price_feed_id)
1773                .with_contracts(&[&deployment.price_feed])
1774                .call()
1775                .await
1776                .is_err()
1777        );
1778        deployment
1779            .price_feed
1780            .methods()
1781            .set_asset_decimals(base_asset, 9)
1782            .call()
1783            .await
1784            .unwrap();
1785        deployment
1786            .price_feed
1787            .methods()
1788            .publish_prices(vec![PriceInput {
1789                asset: base_asset,
1790                bid: 2_000_000_000_000_000_000u64.into(),
1791                ask: 2_000_000_000_000_000_000u64.into(),
1792                timestamp: 3,
1793            }])
1794            .call()
1795            .await
1796            .unwrap();
1797
1798        let parent = Identity::Address(user.address());
1799        let registration_error = deployment
1800            .deploy_account(&deployer, parent, 7)
1801            .await
1802            .expect_err("a caller other than the configured parent must be rejected");
1803        assert!(
1804            format!("{registration_error:?}").contains("NotParent"),
1805            "unexpected registration error: {registration_error:?}"
1806        );
1807        let child = deployment.deploy_account(&user, parent, 7).await.unwrap();
1808        assert!(
1809            deployment
1810                .registry
1811                .methods()
1812                .prop_is_valid(child.contract_id())
1813                .simulate(Execution::state_read_only())
1814                .await
1815                .unwrap()
1816                .value
1817        );
1818        assert_eq!(
1819            child
1820                .methods()
1821                .parent()
1822                .simulate(Execution::state_read_only())
1823                .await
1824                .unwrap()
1825                .value,
1826            parent
1827        );
1828        assert_eq!(
1829            child
1830                .methods()
1831                .index()
1832                .simulate(Execution::state_read_only())
1833                .await
1834                .unwrap()
1835                .value,
1836            7
1837        );
1838        assert_eq!(
1839            child
1840                .methods()
1841                .pool()
1842                .with_contract_ids(&[deployment.oracle_id])
1843                .simulate(Execution::state_read_only())
1844                .await
1845                .unwrap()
1846                .value,
1847            deployment.pool_id
1848        );
1849        assert_eq!(
1850            child
1851                .methods()
1852                .oracle()
1853                .simulate(Execution::state_read_only())
1854                .await
1855                .unwrap()
1856                .value,
1857            deployment.oracle_id
1858        );
1859
1860        let collateral = 2_000_000;
1861        let account = PropAccountContract::new(child.contract_id(), user.clone());
1862        let missing_payment_error = account
1863            .methods()
1864            .start_session(1, collateral, ProlongPeriod::SixHours)
1865            .with_contract_ids(&[
1866                deployment.oracle_id,
1867                deployment.pool_id,
1868                deployment.registry_id,
1869            ])
1870            .call()
1871            .await
1872            .unwrap_err();
1873        assert!(
1874            missing_payment_error
1875                .to_string()
1876                .contains("PaymentAmountMismatch"),
1877            "unexpected missing-payment error: {missing_payment_error:#}"
1878        );
1879        let mismatched_payment_error = account
1880            .methods()
1881            .start_session(1, collateral, ProlongPeriod::SixHours)
1882            .call_params(CallParameters::new(
1883                collateral - 1,
1884                collateral_asset,
1885                u64::MAX,
1886            ))
1887            .unwrap()
1888            .with_contract_ids(&[
1889                deployment.oracle_id,
1890                deployment.pool_id,
1891                deployment.registry_id,
1892            ])
1893            .call()
1894            .await
1895            .unwrap_err();
1896        assert!(
1897            mismatched_payment_error
1898                .to_string()
1899                .contains("PaymentAmountMismatch"),
1900            "unexpected mismatched-payment error: {mismatched_payment_error:#}"
1901        );
1902        let wrong_asset_error = account
1903            .methods()
1904            .start_session(1, collateral, ProlongPeriod::SixHours)
1905            .call_params(CallParameters::new(collateral, base_asset, u64::MAX))
1906            .unwrap()
1907            .with_contract_ids(&[
1908                deployment.oracle_id,
1909                deployment.pool_id,
1910                deployment.registry_id,
1911            ])
1912            .call()
1913            .await
1914            .unwrap_err();
1915        assert!(
1916            wrong_asset_error.to_string().contains("WrongAsset"),
1917            "unexpected wrong-asset error: {wrong_asset_error:#}"
1918        );
1919
1920        let collateral_dust = 17;
1921        let base_dust = 23;
1922        user.force_transfer_to_contract(
1923            child.contract_id(),
1924            collateral_dust,
1925            collateral_asset,
1926            TxPolicies::default(),
1927        )
1928        .await
1929        .unwrap();
1930        user.force_transfer_to_contract(
1931            child.contract_id(),
1932            base_dust,
1933            base_asset,
1934            TxPolicies::default(),
1935        )
1936        .await
1937        .unwrap();
1938        account
1939            .methods()
1940            .start_session(1, collateral, ProlongPeriod::SixHours)
1941            .call_params(CallParameters::new(collateral, collateral_asset, u64::MAX))
1942            .unwrap()
1943            .with_contract_ids(&[
1944                deployment.oracle_id,
1945                deployment.pool_id,
1946                deployment.registry_id,
1947            ])
1948            .with_variable_output_policy(VariableOutputPolicy::Exactly(2))
1949            .call()
1950            .await
1951            .unwrap();
1952        let provider = user.provider();
1953        assert_eq!(
1954            provider
1955                .get_contract_asset_balance(&child.contract_id(), &collateral_asset)
1956                .await
1957                .unwrap(),
1958            0
1959        );
1960        assert_eq!(
1961            provider
1962                .get_contract_asset_balance(&child.contract_id(), &base_asset)
1963                .await
1964                .unwrap(),
1965            0
1966        );
1967
1968        assert!(
1969            deployment
1970                .pool
1971                .methods()
1972                .is_call_allowed(child.contract_id(), order_book.contract_id)
1973                .simulate(Execution::state_read_only())
1974                .await
1975                .unwrap()
1976                .value
1977        );
1978        assert!(
1979            deployment
1980                .pool
1981                .methods()
1982                .is_call_allowed(child.contract_id(), second_order_book.contract_id)
1983                .simulate(Execution::state_read_only())
1984                .await
1985                .unwrap()
1986                .value
1987        );
1988        assert!(
1989            !deployment
1990                .pool
1991                .methods()
1992                .is_call_allowed(child.contract_id(), ContractId::new([0x99; 32]))
1993                .simulate(Execution::state_read_only())
1994                .await
1995                .unwrap()
1996                .value
1997        );
1998
1999        let session = deployment
2000            .pool
2001            .methods()
2002            .get_session(child.contract_id())
2003            .simulate(Execution::state_read_only())
2004            .await
2005            .unwrap()
2006            .value
2007            .expect("prop session opened");
2008        assert_eq!(session.session_id, 1);
2009        assert_eq!(session.credit_line, 10_000_000);
2010        assert_eq!(session.collateral, collateral);
2011
2012        assert!(
2013            deployment
2014                .pool
2015                .methods()
2016                .min_sellable(child.contract_id(), base_asset)
2017                .with_contracts(&[&order_book.order_book, &second_order_book.order_book,])
2018                .simulate(Execution::state_read_only())
2019                .await
2020                .is_err()
2021        );
2022
2023        let best_bid = 2_000_000;
2024        let bid_quantity = 1_000_000_000;
2025        order_book
2026            .order_book
2027            .methods()
2028            .create_order(OrderArgs {
2029                price: best_bid,
2030                quantity: bid_quantity,
2031                order_type: OrderType::Spot,
2032            })
2033            .call_params(CallParameters::new(
2034                bid_quantity * best_bid / 1_000_000_000,
2035                collateral_asset,
2036                u64::MAX,
2037            ))
2038            .unwrap()
2039            .with_variable_output_policy(VariableOutputPolicy::Exactly(1))
2040            .call()
2041            .await
2042            .unwrap();
2043        assert_eq!(
2044            order_book
2045                .order_book
2046                .methods()
2047                .get_base_decimals()
2048                .simulate(Execution::state_read_only())
2049                .await
2050                .unwrap()
2051                .value,
2052            1_000_000_000
2053        );
2054        assert_eq!(
2055            deployment
2056                .pool
2057                .methods()
2058                .min_sellable(child.contract_id(), base_asset)
2059                .with_contracts(&[&order_book.order_book, &second_order_book.order_book,])
2060                .simulate(Execution::state_read_only())
2061                .await
2062                .unwrap()
2063                .value,
2064            Some(500)
2065        );
2066
2067        let close = account
2068            .methods()
2069            .close_session(Vec::<PropOrderBookCleanup>::new())
2070            .with_contracts(&[
2071                &deployment.oracle,
2072                &deployment.pool,
2073                &order_book.order_book,
2074                &second_order_book.order_book,
2075            ])
2076            .with_variable_output_policy(VariableOutputPolicy::Exactly(1))
2077            .call()
2078            .await
2079            .unwrap();
2080        let close_events = close.decode_logs_with_type::<SessionClosed>().unwrap();
2081        assert!(
2082            close
2083                .decode_logs_with_type::<SessionSeized>()
2084                .unwrap()
2085                .is_empty()
2086        );
2087        assert_recent_timestamp(close_events[0].timestamp.unix);
2088        assert_eq!(
2089            close_events,
2090            vec![SessionClosed {
2091                account: child.contract_id(),
2092                session_id: 1,
2093                reason: SettlementReason::UserClose,
2094                v: 10_000_000,
2095                v_is_negative: false,
2096                v_liq: 10_000_000,
2097                v_liq_is_negative: false,
2098                profit_abs: 0,
2099                profit_is_negative: false,
2100                fees_accrued: 0,
2101                platform_total: 0,
2102                user_net: collateral,
2103                bad_debt: 0,
2104                cancelled_debt: vec![],
2105                payout_parent: vec![(collateral_asset, collateral)],
2106                payout_platform: vec![],
2107                timestamp: close_events[0].timestamp.clone(),
2108            }]
2109        );
2110        assert!(
2111            !deployment
2112                .pool
2113                .methods()
2114                .has_session(child.contract_id())
2115                .simulate(Execution::state_read_only())
2116                .await
2117                .unwrap()
2118                .value
2119        );
2120
2121        let excess_collateral = 25_000_000;
2122        account
2123            .methods()
2124            .start_session(1, excess_collateral, ProlongPeriod::SixHours)
2125            .call_params(CallParameters::new(
2126                excess_collateral,
2127                collateral_asset,
2128                u64::MAX,
2129            ))
2130            .unwrap()
2131            .with_contract_ids(&[
2132                deployment.oracle_id,
2133                deployment.pool_id,
2134                deployment.registry_id,
2135            ])
2136            .call()
2137            .await
2138            .unwrap();
2139        let excess_session = deployment
2140            .pool
2141            .methods()
2142            .get_session(child.contract_id())
2143            .simulate(Execution::state_read_only())
2144            .await
2145            .unwrap()
2146            .value
2147            .expect("excess-collateral session opened");
2148        assert_eq!(excess_session.session_id, 2);
2149        assert_eq!(excess_session.collateral, excess_collateral);
2150        assert_eq!(excess_session.credit_line, 20_000_000);
2151
2152        let excess_close = account
2153            .methods()
2154            .close_session(Vec::<PropOrderBookCleanup>::new())
2155            .with_contracts(&[
2156                &deployment.oracle,
2157                &deployment.pool,
2158                &order_book.order_book,
2159                &second_order_book.order_book,
2160            ])
2161            .with_variable_output_policy(VariableOutputPolicy::Exactly(1))
2162            .call()
2163            .await
2164            .unwrap();
2165        let excess_close_events = excess_close
2166            .decode_logs_with_type::<SessionClosed>()
2167            .unwrap();
2168        assert_recent_timestamp(excess_close_events[0].timestamp.unix);
2169        assert_eq!(
2170            excess_close_events,
2171            vec![SessionClosed {
2172                account: child.contract_id(),
2173                session_id: 2,
2174                reason: SettlementReason::UserClose,
2175                v: 33_000_000,
2176                v_is_negative: false,
2177                v_liq: 33_000_000,
2178                v_liq_is_negative: false,
2179                profit_abs: 0,
2180                profit_is_negative: false,
2181                fees_accrued: 0,
2182                platform_total: 0,
2183                user_net: excess_collateral,
2184                bad_debt: 0,
2185                cancelled_debt: vec![],
2186                payout_parent: vec![(collateral_asset, excess_collateral)],
2187                payout_platform: vec![],
2188                timestamp: excess_close_events[0].timestamp.clone(),
2189            }]
2190        );
2191
2192        let withdrawal = 1_000;
2193        user.force_transfer_to_contract(
2194            child.contract_id(),
2195            withdrawal,
2196            collateral_asset,
2197            TxPolicies::default(),
2198        )
2199        .await
2200        .unwrap();
2201        let balance_before = user.get_asset_balance(&collateral_asset).await.unwrap();
2202        let withdraw_response = account
2203            .methods()
2204            .withdraw(collateral_asset, withdrawal)
2205            .with_contract_ids(&[deployment.oracle_id, deployment.pool_id])
2206            .with_variable_output_policy(VariableOutputPolicy::Exactly(1))
2207            .call()
2208            .await
2209            .unwrap();
2210        let withdraw_events = withdraw_response
2211            .decode_logs_with_type::<MarginTradeAccountWithdrawn>()
2212            .unwrap();
2213        assert_recent_timestamp(withdraw_events[0].timestamp.unix);
2214        assert_eq!(
2215            withdraw_events,
2216            vec![MarginTradeAccountWithdrawn {
2217                account: child.contract_id(),
2218                parent,
2219                asset_id: collateral_asset,
2220                amount: withdrawal,
2221                timestamp: withdraw_events[0].timestamp.clone(),
2222            }]
2223        );
2224        assert_eq!(
2225            user.get_asset_balance(&collateral_asset).await.unwrap(),
2226            balance_before + withdrawal as u128
2227        );
2228    }
2229}
2230
2231#[cfg(test)]
2232#[path = "prop_deploy_tests.rs"]
2233mod integration_tests;