@@ -63,6 +63,7 @@ import {
6363 MAX_VTXOS_PER_SETTLEMENT ,
6464 SettlementConfig ,
6565 VtxoManager ,
66+ selectPendingRecoveryOutpoints ,
6667} from "./vtxo-manager" ;
6768import { ArkNote } from "../arknote" ;
6869import { Intent } from "../intent" ;
@@ -392,25 +393,30 @@ export class ReadonlyWallet implements IReadonlyWallet {
392393 * current-signer-only (a deprecated-signer input in a plain settle() is
393394 * rejected; old-signer recovery goes through the migration API).
394395 */
395- protected _deprecatedSignerHexes : Set < string > = new Set ( ) ;
396+ protected _deprecatedSigners : Map < string , bigint > = new Map ( ) ;
396397
397398 /**
398399 * Refresh the cached deprecated-signer set from a fresh server-info
399400 * snapshot. Called by the create() factories at construction and by the
400401 * server-info-change handler mid-session. Lenient: a malformed deprecated
401402 * entry is skipped, never fatal to wallet creation.
402403 */
403- refreshDeprecatedSigners ( info : { deprecatedSigners ?: readonly { pubkey ?: string } [ ] } ) : void {
404- const set = new Set < string > ( ) ;
404+ refreshDeprecatedSigners ( info : {
405+ deprecatedSigners ?: readonly { pubkey ?: string ; cutoffDate ?: bigint } [ ] ;
406+ } ) : void {
407+ const next = new Map < string , bigint > ( ) ;
405408 for ( const s of info . deprecatedSigners ?? [ ] ) {
406409 if ( ! s . pubkey ) continue ;
407410 try {
408- set . add ( toXOnlySignerHex ( s . pubkey ) ) ;
411+ // `0n` is arkd's sentinel for "no cutoff advertised" (→ DUE_NOW);
412+ // a positive cutoff that has already passed is EXPIRED. The
413+ // spendability split in getBalance / coin selection reads this.
414+ next . set ( toXOnlySignerHex ( s . pubkey ) , s . cutoffDate ?? 0n ) ;
409415 } catch ( e ) {
410416 console . warn ( "Skipping malformed deprecated signer pubkey" , s . pubkey , e ) ;
411417 }
412418 }
413- this . _deprecatedSignerHexes = set ;
419+ this . _deprecatedSigners = next ;
414420 }
415421
416422 /**
@@ -421,7 +427,7 @@ export class ReadonlyWallet implements IReadonlyWallet {
421427 protected watchedBoardingSigners ( ) : Set < string > {
422428 return new Set ( [
423429 toXOnlySignerHex ( hex . encode ( this . boardingTapscript . options . serverPubKey ) ) ,
424- ...this . _deprecatedSignerHexes ,
430+ ...this . _deprecatedSigners . keys ( ) ,
425431 ] ) ;
426432 }
427433
@@ -719,10 +725,13 @@ export class ReadonlyWallet implements IReadonlyWallet {
719725 * Return the wallet's combined onchain and offchain balances.
720726 */
721727 async getBalance ( ) : Promise < WalletBalance > {
722- const [ boardingUtxos , vtxos ] = await Promise . all ( [
728+ const [ boardingUtxos , vtxos , pendingOutpoints ] = await Promise . all ( [
723729 this . getBoardingUtxos ( ) ,
724730 this . getVtxos ( ) ,
731+ this . pendingRecoveryOutpoints ( ) ,
725732 ] ) ;
733+ const isPendingRecovery = ( coin : ExtendedVirtualCoin ) =>
734+ pendingOutpoints . has ( `${ coin . txid } :${ coin . vout } ` ) ;
726735
727736 // boarding
728737 let confirmed = 0 ;
@@ -739,18 +748,27 @@ export class ReadonlyWallet implements IReadonlyWallet {
739748 let settled = 0 ;
740749 let preconfirmed = 0 ;
741750 let recoverable = 0 ;
751+ let pendingRecovery = 0 ;
752+ // Funds under a past-cutoff (EXPIRED) deprecated signer that are not yet
753+ // swept are NOT spendable — excluded from settled/preconfirmed/available
754+ // and surfaced under `pendingRecovery` (they still count toward `total`).
742755 settled = vtxos
743- . filter ( ( coin ) => coin . virtualStatus . state === "settled" )
756+ . filter ( ( coin ) => coin . virtualStatus . state === "settled" && ! isPendingRecovery ( coin ) )
744757 . reduce ( ( sum , coin ) => sum + coin . value , 0 ) ;
745758 preconfirmed = vtxos
746- . filter ( ( coin ) => coin . virtualStatus . state === "preconfirmed" )
759+ . filter (
760+ ( coin ) => coin . virtualStatus . state === "preconfirmed" && ! isPendingRecovery ( coin ) ,
761+ )
747762 . reduce ( ( sum , coin ) => sum + coin . value , 0 ) ;
748763 recoverable = vtxos
749764 . filter ( ( coin ) => isSpendable ( coin ) && coin . virtualStatus . state === "swept" )
750765 . reduce ( ( sum , coin ) => sum + coin . value , 0 ) ;
766+ pendingRecovery = vtxos
767+ . filter ( isPendingRecovery )
768+ . reduce ( ( sum , coin ) => sum + coin . value , 0 ) ;
751769
752770 const totalBoarding = confirmed + unconfirmed ;
753- const totalOffchain = settled + preconfirmed + recoverable ;
771+ const totalOffchain = settled + preconfirmed + recoverable + pendingRecovery ;
754772
755773 // aggregate asset balances from spendable virtual outputs
756774 const assetBalances = new Map < string , bigint > ( ) ;
@@ -778,6 +796,7 @@ export class ReadonlyWallet implements IReadonlyWallet {
778796 preconfirmed,
779797 available : settled + preconfirmed ,
780798 recoverable,
799+ pendingRecovery,
781800 total : totalBoarding + totalOffchain ,
782801 assets,
783802 } ;
@@ -809,6 +828,24 @@ export class ReadonlyWallet implements IReadonlyWallet {
809828 } ) ;
810829 }
811830
831+ /**
832+ * Outpoints of VTXOs whose deprecated signer is past its cutoff (EXPIRED) and
833+ * which have not yet been swept — unspendable until they recover. Offline:
834+ * classifies the repo's contracts against the cached signer set (active +
835+ * {@link _deprecatedSigners}, cutoffs included). Empty fast-path when no
836+ * signer is deprecated. Consumed by {@link getBalance} (the `pendingRecovery`
837+ * bucket) and the send coin-selection path so neither counts nor spends them.
838+ */
839+ async pendingRecoveryOutpoints ( ) : Promise < Set < string > > {
840+ if ( this . _deprecatedSigners . size === 0 ) return new Set ( ) ;
841+ const contractManager = await this . getContractManager ( ) ;
842+ const contractsWithVtxos = await contractManager . getContractsWithVtxos ( ) ;
843+ return selectPendingRecoveryOutpoints ( contractsWithVtxos , {
844+ active : toXOnlySignerHex ( hex . encode ( this . offchainTapscript . options . serverPubKey ) ) ,
845+ deprecated : this . _deprecatedSigners ,
846+ } ) ;
847+ }
848+
812849 /**
813850 * Return wallet transaction history derived from Arkade state and boarding transactions.
814851 */
@@ -2421,10 +2458,10 @@ export class Wallet extends ReadonlyWallet implements IWallet {
24212458 this . watcherConfig ,
24222459 this . walletContractTimelocks ,
24232460 ) ;
2424- // Carry the cached deprecated-signer set so the clone's boarding watch
2425- // path stays as wide as the source wallet's (same operator/info) .
2426- ( readonly as unknown as { _deprecatedSignerHexes : Set < string > } ) . _deprecatedSignerHexes =
2427- new Set ( this . _deprecatedSignerHexes ) ;
2461+ // Carry the cached deprecated-signer set (with cutoffs) so the clone's
2462+ // boarding watch path and spendability split match the source wallet's.
2463+ ( readonly as unknown as { _deprecatedSigners : Map < string , bigint > } ) . _deprecatedSigners =
2464+ new Map ( this . _deprecatedSigners ) ;
24282465 return readonly ;
24292466 }
24302467
@@ -3490,9 +3527,15 @@ export class Wallet extends ReadonlyWallet implements IWallet {
34903527 // validate recipients and populate undefined amount with dust amount
34913528 const recipients = validateRecipients ( args , Number ( this . dustAmount ) ) ;
34923529
3493- const virtualCoins = await this . getVtxos ( {
3530+ const allVirtualCoins = await this . getVtxos ( {
34943531 withRecoverable : false ,
34953532 } ) ;
3533+ // Drop funds under a past-cutoff (EXPIRED) deprecated signer: the operator
3534+ // will not co-sign a spend of them, so selecting one would fail at submit.
3535+ const pendingRecovery = await this . pendingRecoveryOutpoints ( ) ;
3536+ const virtualCoins = pendingRecovery . size
3537+ ? allVirtualCoins . filter ( ( c ) => ! pendingRecovery . has ( `${ c . txid } :${ c . vout } ` ) )
3538+ : allVirtualCoins ;
34963539
34973540 // keep track of asset changes
34983541 const assetChanges = new Map < string , bigint > ( ) ;
0 commit comments