Skip to content

Commit 9d1fcb8

Browse files
authored
Merge branch 'feat/frontend/track-cfs-sign-event' into fix/frontend/signer-unavailable-toast
2 parents 4cb3e28 + cf7a72c commit 9d1fcb8

6 files changed

Lines changed: 205 additions & 0 deletions
-10.1 KB
Loading
11 KB
Loading
-2.13 KB
Loading
3.75 KB
Loading

e2e/styles/masks.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@
1212
backdrop-filter: none !important;
1313
-webkit-backdrop-filter: none !important;
1414
}
15+
16+
/* A native scrollbar only reserves layout width when content overflows, which
17+
varies between runs (font/image load timing). For element screenshots like
18+
the about-why-oisy modal this narrows the captured width by the scrollbar
19+
size (~11px) and reflows the centred content, producing a visually-identical
20+
but pixel-different snapshot that the bot keeps regenerating. Hide scrollbars
21+
so they never reserve space and the captured width stays deterministic. */
22+
* {
23+
scrollbar-width: none !important;
24+
}
25+
26+
*::-webkit-scrollbar {
27+
display: none !important;
28+
}

src/backend/tests/it/signer.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,197 @@ fn test_housekeeping_resumes_after_cycles_ledger_becomes_available() {
334334
);
335335
}
336336

337+
/// Regression test for the **automatic** (timer-driven) cycles-ledger top-up.
338+
///
339+
/// The hourly housekeeping timer must actually *deposit* cycles into the
340+
/// backend's cycles-ledger account when the balance is below threshold — not
341+
/// merely leave the canister responsive (which is all
342+
/// `test_housekeeping_resumes_after_cycles_ledger_becomes_available` checks).
343+
/// If the timer or its spawned task silently stops refilling the patron
344+
/// account, it drains until threshold signing (BTC / EVM / SOL sends) fails
345+
/// wallet-wide via `PatronPaysIcrc2Cycles`. This asserts the deposit happens.
346+
#[test]
347+
fn test_housekeeping_timer_deposits_into_cycles_ledger() {
348+
use candid::{decode_one, encode_one};
349+
use ic_cycles_ledger_client::Account;
350+
351+
let pic_setup = BackendBuilder::default().with_cycles_ledger(true).deploy();
352+
let backend_id = pic_setup.canister_id;
353+
// The cycles ledger is installed at its mainnet id in the test harness.
354+
let cycles_ledger_id =
355+
Principal::from_text("um5iw-rqaaa-aaaaq-qaaba-cai").expect("cycles ledger id");
356+
357+
let balance_of = || -> Nat {
358+
let arg = encode_one(Account {
359+
owner: backend_id,
360+
subaccount: None,
361+
})
362+
.unwrap();
363+
let reply = pic_setup
364+
.pic
365+
.query_call(cycles_ledger_id, controller(), "icrc1_balance_of", arg)
366+
.expect("icrc1_balance_of query should succeed");
367+
decode_one(&reply).expect("decode cycles-ledger balance")
368+
};
369+
370+
// Let the immediate (init) housekeeping top-up settle first, so `before`
371+
// already reflects it. The assertion then isolates the *hourly* interval,
372+
// rather than passing on the immediate run even if the interval is broken.
373+
for _ in 0..20 {
374+
pic_setup.pic.tick();
375+
}
376+
let before = balance_of();
377+
378+
// Advancing past the hourly interval must trigger a further deposit.
379+
pic_setup.pic.advance_time(Duration::from_hours(1));
380+
for _ in 0..20 {
381+
pic_setup.pic.tick();
382+
}
383+
let after = balance_of();
384+
385+
assert!(
386+
after > before,
387+
"hourly housekeeping timer must top up the cycles-ledger account \
388+
(before={before}, after={after})"
389+
);
390+
}
391+
392+
/// Faithfully reproduces the production send outage.
393+
///
394+
/// BTC / EVM / SOL sends are signed by the chain-fusion signer, which charges
395+
/// its fee by pulling cycles from the backend's **shared** cycles-ledger
396+
/// account (the "patron") via `icrc2_transfer_from`, using the allowance
397+
/// `allow_signing` grants it. When that shared pool is drained, every signing
398+
/// operation fails with `InsufficientFunds` — the exact error users saw for
399+
/// BTC and EVM sends. This test drives the real drain path (acting as the
400+
/// signer) to reproduce the failure, then verifies the housekeeping top-up
401+
/// refills the pool and signing recovers without manual intervention.
402+
#[test]
403+
fn test_signer_fee_pull_fails_when_patron_drained_and_recovers_after_topup() {
404+
use candid::{decode_one, encode_one, Nat};
405+
use ic_cycles_ledger_client::{Account as ClAccount, TransferFromArgs, TransferFromError};
406+
use serde_bytes::ByteBuf;
407+
408+
/// Mirrors `signer::service::principal2account`: the signer's allowance is
409+
/// keyed by the user's principal encoded as a ledger subaccount.
410+
fn principal_to_subaccount(principal: &Principal) -> ByteBuf {
411+
let hex_str = ic_ledger_types::AccountIdentifier::new(
412+
principal,
413+
&ic_ledger_types::Subaccount([0u8; 32]),
414+
)
415+
.to_hex();
416+
ByteBuf::from(hex::decode(hex_str).expect("valid account hex"))
417+
}
418+
419+
let pic_setup = setup_with_cycles_ledger();
420+
let backend_id = pic_setup.canister_id;
421+
let signer = Principal::from_text(crate::utils::mock::SIGNER_CANISTER_ID).expect("signer id");
422+
let user = Principal::from_text(USER_1).expect("user id");
423+
let cycles_ledger_id =
424+
Principal::from_text("um5iw-rqaaa-aaaaq-qaaba-cai").expect("cycles ledger id");
425+
426+
// Logging in makes the backend grant the signer an allowance to spend from
427+
// its shared cycles-ledger account on this user's behalf, and the
428+
// housekeeping top-up funds that shared account.
429+
call_create_user_profile(&pic_setup, user).expect("create user profile");
430+
pic_setup.pic.advance_time(Duration::from_hours(1));
431+
for _ in 0..20 {
432+
pic_setup.pic.tick();
433+
}
434+
435+
let spender_subaccount = principal_to_subaccount(&user);
436+
let patron = ClAccount {
437+
owner: backend_id,
438+
subaccount: None,
439+
};
440+
let signer_account = ClAccount {
441+
owner: signer,
442+
subaccount: None,
443+
};
444+
445+
let balance_of = |account: &ClAccount| -> Nat {
446+
let reply = pic_setup
447+
.pic
448+
.query_call(
449+
cycles_ledger_id,
450+
signer,
451+
"icrc1_balance_of",
452+
encode_one(account).unwrap(),
453+
)
454+
.expect("icrc1_balance_of query");
455+
decode_one(&reply).expect("decode balance")
456+
};
457+
458+
// The signer pulls `amount` from the shared patron account — the exact path
459+
// the chain-fusion signer takes to charge a signing fee.
460+
let signer_pull = |amount: Nat| -> std::result::Result<Nat, TransferFromError> {
461+
let args = TransferFromArgs {
462+
to: signer_account.clone(),
463+
fee: None,
464+
spender_subaccount: Some(spender_subaccount.clone()),
465+
from: patron.clone(),
466+
memo: None,
467+
created_at_time: None,
468+
amount,
469+
};
470+
let reply = pic_setup
471+
.pic
472+
.update_call(
473+
cycles_ledger_id,
474+
signer,
475+
"icrc2_transfer_from",
476+
encode_one(args).unwrap(),
477+
)
478+
.expect("icrc2_transfer_from ingress");
479+
decode_one(&reply).expect("decode transfer_from result")
480+
};
481+
482+
// One signing operation's worth of cycles (LEDGER_FEE + SIGNER_FEE), and the
483+
// ledger transfer fee.
484+
let op_fee = Nat::from(81_000_000_000u64);
485+
let ledger_fee = Nat::from(1_000_000_000u64);
486+
487+
// The top-up funded the shared pool, so a signing-fee pull succeeds.
488+
let funded = balance_of(&patron);
489+
assert!(
490+
funded > op_fee,
491+
"patron pool should be funded by the housekeeping top-up (got {funded})"
492+
);
493+
signer_pull(op_fee.clone()).expect("signing-fee pull should succeed while funded");
494+
495+
// Drain the shared pool to (near) zero, simulating heavy signing load.
496+
let remaining = balance_of(&patron);
497+
signer_pull(remaining - ledger_fee.clone()).expect("drain pull should succeed");
498+
499+
// Now the pool is empty: the next signing-fee pull fails with the exact
500+
// error users saw in production.
501+
let drained = balance_of(&patron);
502+
assert!(drained < op_fee, "pool should be drained (got {drained})");
503+
match signer_pull(op_fee.clone()) {
504+
Err(TransferFromError::InsufficientFunds { balance }) => {
505+
assert_eq!(balance, drained, "error should report the drained balance");
506+
}
507+
other => panic!("expected InsufficientFunds, got {other:?}"),
508+
}
509+
510+
// Recovery: the hourly housekeeping top-up refills the shared pool and
511+
// signing works again — no manual intervention. In production the backend
512+
// canister holds ample raw cycles (tens of T); mirror that so the top-up
513+
// has cycles to forward (the tiny default test budget is otherwise spent by
514+
// repeated top-ups and time-advance burn).
515+
pic_setup.pic.add_cycles(backend_id, 100_000_000_000_000);
516+
pic_setup.pic.advance_time(Duration::from_hours(1));
517+
for _ in 0..20 {
518+
pic_setup.pic.tick();
519+
}
520+
let refilled = balance_of(&patron);
521+
assert!(
522+
refilled > op_fee,
523+
"housekeeping top-up should refill the pool (got {refilled})"
524+
);
525+
signer_pull(op_fee).expect("signing-fee pull should succeed after top-up");
526+
}
527+
337528
// -------------------------------------------------------------------------------------------------
338529
// - Rate-limit integration tests for allow_signing
339530
// -------------------------------------------------------------------------------------------------

0 commit comments

Comments
 (0)