o2-tools 0.4.7-rc5

Reusable tooling for trade account and order book contract interactions on Fuel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! A sweep of the guards and read surfaces the scenario tests never reach.
//!
//! Written against a mechanical audit rather than a story: every
//! `PropMarginPoolError` variant and every ABI method was checked against the
//! suite, and these are the ones nothing touched. That leaves eight variants
//! unasserted, and they are not all the same kind of thing:
//!
//! DEAD - declared and never constructed anywhere in the contract, so no test
//! can reach them: `OverReturn`, `CashShortOfDrawn`, `PositionsOutstanding`.
//!
//! UNREACHABLE from contract state, standing as defence in depth:
//! `NotPropAccount` (an unregistered proxy has no parent in storage, so
//! `InvalidParent` fires first and the pool is never asked); `SweepIncomplete`
//! (`pool_sweep` forwards exactly the asset vector the check then re-reads);
//! `PriceInvalid` (the feed rejects `bid == 0` and `ask < bid` on every push,
//! and a retraction surfaces as `PriceMissing` - see the retraction test
//! below - so only a REPLACEMENT feed could produce a malformed record).
//!
//! GENUINELY UNCOVERED, and the two worth writing next: `TooManyBooks`, which
//! needs a pool deployed with a small `MAX_TIER_BOOKS`, and `ValueOverflow`,
//! which needs a mark large enough to overflow the `u256 -> u64` narrowing in
//! the closing event. Nothing on chain bounds a mark: `require_price_shape`
//! checks only sign and decimals, and `TierParams::price_band_bps` is
//! validated at publication and read nowhere.

use super::*;

/// The `AccessControl` role the feed's emergency retraction sits behind.
const FEED_PAUSER_ROLE: u64 = 1;

// ---------------------------------------------------------------------------
// Funding somebody else's session.
// ---------------------------------------------------------------------------

/// An account with a session of its own may top up ONLY itself. Money moving
/// between two live sessions would let a trader park a loss on one seat and
/// the profit share on another, and both ledgers would still add up.
///
/// An OUTSIDE funder - a wallet, or a contract with no session - is
/// unrestricted, which is what makes a rescue possible.
#[tokio::test]
async fn a_session_may_top_up_itself_but_never_another_session() {
    let mut fixture = PropFixture::new().await;

    // A second account with a session and cash of its own.
    let parent = Identity::Address(fixture.user.address());
    let sibling = fixture
        .deployment
        .deploy_account(&fixture.user, parent, 41)
        .await
        .unwrap();
    let sibling_id = sibling.contract_id();
    let sibling_account = PropAccountContract::new(sibling_id, fixture.user.clone());
    let expiry = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
        + 86_400;
    sibling_account
        .methods()
        .set_session(ParallelSessionArgs {
            nonce: U256::zero(),
            session_id: Identity::Address(fixture.user.address()),
            expiry: Time { unix: expiry },
            contract_ids: vec![],
        })
        .with_contract_ids(&[fixture.deployment.oracle_id])
        .call()
        .await
        .unwrap();
    sibling_account
        .methods()
        .start_session(TIER_ID, COLLATERAL, ProlongPeriod::SixHours)
        .call_params(CallParameters::new(
            COLLATERAL,
            fixture.collateral_asset,
            u64::MAX,
        ))
        .unwrap()
        .with_contract_ids(&[
            fixture.deployment.oracle_id,
            fixture.deployment.pool_id,
            fixture.deployment.registry_id,
        ])
        .with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
        .call()
        .await
        .unwrap();
    fixture
        .deployer
        .force_transfer_to_contract(
            sibling_id,
            500_000,
            fixture.collateral_asset,
            TxPolicies::default(),
        )
        .await
        .unwrap();

    // The sibling tries to fund the fixture's session out of its own cash.
    let fund_other = CallContractArg {
        contract_id: fixture.deployment.pool_id,
        function_selector: Bytes(fn_selector!(add_collateral(ContractId))),
        call_params: CallParams {
            coins: 500_000,
            asset_id: fixture.collateral_asset,
            gas: 30_000_000,
        },
        call_data: Some(Bytes(call_data!(fixture.child_id))),
    };
    let (user_signature, cosigner_signature, calls) =
        fixture.signed_calls_for(sibling_id, vec![fund_other]).await;
    let error = sibling_account
        .methods()
        .call_contracts(user_signature, cosigner_signature, calls)
        .with_contracts(&[
            &fixture.deployment.oracle,
            &fixture.deployment.pool,
            &fixture.deployment.price_feed,
            &fixture.deployment.registry,
            &fixture.order_book.order_book,
        ])
        .with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
        .call()
        .await
        .unwrap_err();
    assert!(
        error.to_string().contains("FundOtherAccount"),
        "one session may not capitalise another: {error:#}"
    );
    assert_eq!(fixture.session().await.collateral, COLLATERAL);

    // The same money from a party with no session of its own is welcome.
    fixture.fund_collateral(500_000).await;
    assert_eq!(fixture.session().await.collateral, COLLATERAL + 500_000);
    assert_eq!(
        fixture.session().await.capitalised,
        0,
        "an outside rescue is not the user's own profit and bears no share"
    );
}

// ---------------------------------------------------------------------------
// Reading a tier that is not there.
// ---------------------------------------------------------------------------

/// The tier catalogue's read surface answers for a version that exists and
/// refuses one that does not, rather than returning an empty list a caller
/// would read as "this tier trades nothing".
#[tokio::test]
async fn the_tier_catalogue_refuses_a_version_it_never_published() {
    let fixture = PropFixture::new().await;
    let pool = &fixture.deployment.pool;

    assert_eq!(
        pool.methods()
            .tier_books(TIER_ID, 1)
            .simulate(Execution::state_read_only())
            .await
            .unwrap()
            .value,
        vec![fixture.order_book.contract_id]
    );
    let assets = pool
        .methods()
        .tier_assets(TIER_ID, 1)
        .simulate(Execution::state_read_only())
        .await
        .unwrap()
        .value;
    assert!(assets.contains(&fixture.base_asset));
    assert!(assets.contains(&fixture.collateral_asset));

    for (name, error) in [
        (
            "tier_assets",
            pool.methods()
                .tier_assets(TIER_ID, 99)
                .simulate(Execution::state_read_only())
                .await
                .unwrap_err(),
        ),
        (
            "tier_books",
            pool.methods()
                .tier_books(TIER_ID, 99)
                .simulate(Execution::state_read_only())
                .await
                .unwrap_err(),
        ),
        (
            "tier_assets on an unknown tier",
            pool.methods()
                .tier_assets(4_242, 1)
                .simulate(Execution::state_read_only())
                .await
                .unwrap_err(),
        ),
    ] {
        assert!(
            error.to_string().contains("InvalidTierVersion"),
            "{name} must refuse rather than answer empty: {error:#}"
        );
    }

    // The role bitmap reads back what `initialize` granted.
    let roles = pool
        .methods()
        .get_roles(Identity::Address(fixture.deployer.address()))
        .simulate(Execution::state_read_only())
        .await
        .unwrap()
        .value;
    assert_eq!(roles & 1, 1, "the deployer holds DEFAULT_ADMIN_ROLE");
    assert_eq!(
        pool.methods()
            .get_roles(Identity::Address(fixture.outsider.address()))
            .simulate(Execution::state_read_only())
            .await
            .unwrap()
            .value,
        0
    );
}

// ---------------------------------------------------------------------------
// A feed that has no answer, and one that retracts the answer it gave.
// ---------------------------------------------------------------------------

/// A tier may admit an asset the feed can SCALE but has never QUOTED. That is
/// deliberate - `pin_price_decimals` says so in as many words - so a
/// catalogue can be published before its publishers are live.
///
/// The cost is real and belongs beside the promise: the asset joins every
/// running session's valuation walk, so from the moment anything holds it,
/// every exit demands a quote that does not exist. The walk skips an asset
/// whose holding and debt are both zero, which is the only reason a live
/// session survives the admission at all.
#[tokio::test]
async fn a_tier_may_admit_a_market_before_its_publisher_is_live() {
    let fixture = PropFixture::new().await;

    let quiet_asset = AssetId::new([0xc1; 32]);
    let quiet_book = OrderBookDeploy::deploy(
        &fixture.deployer,
        quiet_asset,
        fixture.collateral_asset,
        &OrderBookDeployConfig::default(),
    )
    .await
    .unwrap();
    fixture
        .deployment
        .price_feed
        .methods()
        .set_asset_decimals(quiet_asset, 9)
        .call()
        .await
        .unwrap();

    // Admission succeeds on decimals alone - no price required.
    fixture
        .deployment
        .pool
        .methods()
        .add_books(TIER_ID, vec![quiet_book.contract_id])
        .with_contract_ids(&[quiet_book.contract_id, fixture.deployment.price_feed_id])
        .call()
        .await
        .unwrap();
    let assets = fixture
        .deployment
        .pool
        .methods()
        .session_assets(fixture.child_id)
        .simulate(Execution::state_read_only())
        .await
        .unwrap()
        .value;
    assert!(
        assets.contains(&quiet_asset),
        "the live session's walk now covers an asset nobody quotes"
    );

    // Holding none of it, the session values and closes exactly as before.
    let lines = fixture
        .deployment
        .pool
        .methods()
        .get_session_lines(fixture.child_id)
        .with_contract_ids(&[
            fixture.child_id,
            fixture.order_book.contract_id,
            quiet_book.contract_id,
            fixture.deployment.price_feed_id,
        ])
        .simulate(Execution::state_read_only())
        .await
        .unwrap()
        .value;
    assert_eq!(lines.threshold, U256::from(8_250_000u64));

    // The close must now name the new book too: the drain check and the sweep
    // walk every book the tier admits, so widening a tier widens the input set
    // every exit transaction has to carry.
    let response = fixture
        .account
        .methods()
        .close_session(Vec::<crate::prop::PropOrderBookCleanup>::new())
        .with_contracts(&[
            &fixture.deployment.pool,
            &fixture.deployment.oracle,
            &fixture.order_book.order_book,
            &quiet_book.order_book,
            &fixture.deployment.price_feed,
        ])
        .with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
        .call()
        .await
        .unwrap();
    let closed = fixture.closed_event(&response);
    assert_eq!(closed.reason, SettlementReason::UserClose);
    assert_eq!(closed.user_net, COLLATERAL);
}

/// The feed's emergency retraction is the companion to its pause: pause halts
/// further pushes, invalidation withdraws a price that already landed. The
/// pool must read that as "no usable price" rather than as a mark of zero -
/// which would value every holding at nothing and hand the keeper a free
/// seizure of a healthy session.
///
/// It surfaces as `PriceMissing`, not `PriceInvalid`: the feed zeroes the bid
/// internally but `live_price()` then reports absence, so the pool never sees
/// a malformed record. `PriceInvalid` is unreachable through this feed - it
/// rejects `bid == 0` and `ask < bid` on every push - and stands as defence
/// against a replacement feed that does not.
#[tokio::test]
async fn a_retracted_price_is_refused_rather_than_marked_at_zero() {
    let mut fixture = PropFixture::new().await;

    // A position, so the walk actually reads the base price.
    let borrow = fixture.pool_call(
        fn_selector!(borrow(AssetId, u64)),
        0,
        AssetId::default(),
        Some(call_data!(fixture.base_asset, 100_000_000u64)),
    );
    fixture.call_account(vec![borrow], 1).await.unwrap();
    assert!(!fixture.is_liquidatable().await);

    // The incident: a pauser retracts the standing observation.
    fixture
        .deployment
        .price_feed
        .methods()
        .grant_role(
            FEED_PAUSER_ROLE,
            Identity::Address(fixture.deployer.address()),
        )
        .call()
        .await
        .unwrap();
    fixture
        .deployment
        .price_feed
        .methods()
        .invalidate_offchain_price(fixture.base_asset)
        .call()
        .await
        .unwrap();

    let error = fixture
        .deployment
        .pool
        .methods()
        .get_session_lines(fixture.child_id)
        .with_contract_ids(&[
            fixture.child_id,
            fixture.order_book.contract_id,
            fixture.deployment.price_feed_id,
        ])
        .simulate(Execution::state_read_only())
        .await
        .unwrap_err();
    assert!(
        error.to_string().contains("PriceMissing"),
        "a retracted price is an absence, not a mark of zero: {error:#}"
    );

    // And the keeper cannot seize on it either - which is the whole point of
    // failing closed rather than marking the position to nothing.
    let error = fixture
        .deployment
        .pool
        .clone()
        .with_account(fixture.outsider.clone())
        .methods()
        .liquidate(fixture.child_id, vec![])
        .with_contracts(&[
            &fixture.account,
            &fixture.deployment.oracle,
            &fixture.order_book.order_book,
            &fixture.deployment.price_feed,
        ])
        .with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
        .call()
        .await
        .unwrap_err();
    assert!(
        error.to_string().contains("PriceMissing"),
        "a retraction must not hand the keeper a free seizure: {error:#}"
    );

    // The in-kind exit is ungated, so the user is not trapped by the incident.
    let repay =
        fixture.pool_call(fn_selector!(repay()), 100_000_000, fixture.base_asset, None);
    fixture.call_account(vec![repay], 0).await.unwrap();
    assert_eq!(fixture.debt(fixture.base_asset).await, 0);
    fixture.close_session().await;
}