@@ -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