Skip to content

Commit 00ed22d

Browse files
committed
feat(wallet): exclude post-cutoff deprecated-signer funds from spendable balance
Funds locked under a deprecated signer whose cutoff has passed (EXPIRED) can no longer be cooperatively spent — the operator won't co-sign with the old key — until the server sweeps their batch and they recover under the active signer. Until now they were still counted in getBalance().available and were selectable for sends (which would then fail at submit). - Cache deprecated-signer cutoffs (Map<hex, cutoff>) so the wallet classifies EXPIRED vs still-spendable MIGRATABLE/DUE_NOW offline. - selectPendingRecoveryOutpoints(): pure classifier returning the outpoints of EXPIRED, not-yet-swept VTXOs (already-swept ones stay in `recoverable`). - getBalance() buckets those under a new WalletBalance.pendingRecovery field, out of settled/preconfirmed/available (still counted toward total). - Send coin selection drops them so a spend never picks an uncosignable VTXO. - Service-worker getBalance mirrors the same split.
1 parent 6d0841d commit 00ed22d

6 files changed

Lines changed: 208 additions & 19 deletions

File tree

‎packages/ts-sdk/src/wallet/index.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,15 @@ export interface WalletBalance {
261261
/** Recoverable balance from subdust or expired (swept) virtual outputs. */
262262
recoverable: number;
263263

264-
/** Total balance across offchain, recoverable, and boarding funds. */
264+
/**
265+
* Funds under a now-deprecated signer past its cutoff (EXPIRED) that have not
266+
* yet been swept by the server. NOT spendable until they recover, so excluded
267+
* from `available`/`settled`/`preconfirmed` and from coin selection — but
268+
* still the wallet's funds, so counted in `total`.
269+
*/
270+
pendingRecovery: number;
271+
272+
/** Total balance across offchain, recoverable, pending-recovery, and boarding funds. */
265273
total: number;
266274

267275
/** Asset balance entries (`assetId` & `amount`) */

‎packages/ts-sdk/src/wallet/serviceWorker/wallet-message-handler.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,9 +1317,12 @@ export class WalletMessageHandler
13171317
}
13181318

13191319
private async handleGetBalance() {
1320-
const [boardingUtxos, allVtxos] = await Promise.all([
1320+
const [boardingUtxos, allVtxos, pendingOutpoints] = await Promise.all([
13211321
this.getAllBoardingUtxos(),
13221322
this.getVtxosFromRepo(),
1323+
this.readonlyWallet
1324+
? this.readonlyWallet.pendingRecoveryOutpoints()
1325+
: Promise.resolve(new Set<string>()),
13231326
]);
13241327

13251328
// boarding
@@ -1340,8 +1343,13 @@ export class WalletMessageHandler
13401343
let settled = 0;
13411344
let preconfirmed = 0;
13421345
let recoverable = 0;
1346+
let pendingRecovery = 0;
1347+
// Past-cutoff (EXPIRED) deprecated-signer funds not yet swept are NOT
1348+
// spendable — bucket them under pendingRecovery, out of settled/preconfirmed.
13431349
for (const vtxo of spendableVtxos) {
1344-
if (vtxo.virtualStatus.state === "settled") {
1350+
if (pendingOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
1351+
pendingRecovery += vtxo.value;
1352+
} else if (vtxo.virtualStatus.state === "settled") {
13451353
settled += vtxo.value;
13461354
} else if (vtxo.virtualStatus.state === "preconfirmed") {
13471355
preconfirmed += vtxo.value;
@@ -1354,7 +1362,7 @@ export class WalletMessageHandler
13541362
}
13551363

13561364
const totalBoarding = confirmed + unconfirmed;
1357-
const totalOffchain = settled + preconfirmed + recoverable;
1365+
const totalOffchain = settled + preconfirmed + recoverable + pendingRecovery;
13581366

13591367
// aggregate asset balances from spendable virtual outputs
13601368
const assetBalances = new Map<string, bigint>();
@@ -1381,6 +1389,7 @@ export class WalletMessageHandler
13811389
preconfirmed,
13821390
available: settled + preconfirmed,
13831391
recoverable,
1392+
pendingRecovery,
13841393
total: totalBoarding + totalOffchain,
13851394
assets,
13861395
};

‎packages/ts-sdk/src/wallet/vtxo-manager.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isSpendable,
99
isSubdust,
1010
Outpoint,
11+
VirtualCoin,
1112
} from ".";
1213
import { ArkInfo, ArkProvider, SettlementEvent } from "../providers/ark";
1314
import { maybeArkError } from "../providers/errors";
@@ -34,6 +35,41 @@ import type { Network } from "../networks";
3435
import type { DefaultVtxo } from "../script/default";
3536
import { getDustAmount } from "./utils";
3637

38+
/**
39+
* Outpoints (`txid:vout`) of VTXOs that are NOT cooperatively spendable because
40+
* their contract's signer is past its cutoff (`EXPIRED`) and the VTXO has not
41+
* yet been swept by the server. Such funds are unspendable until they recover
42+
* (the server sweeps the batch at expiry, then the swept output re-settles under
43+
* the active signer), so `getBalance` buckets them under `pendingRecovery` and
44+
* coin selection skips them — otherwise a send would pick a VTXO the operator
45+
* will not co-sign and fail at submit.
46+
*
47+
* Pure + offline: classification uses a cached {@link SignerSet}, never a fresh
48+
* GetInfo. `MIGRATABLE` / `DUE_NOW` (still cooperatively spendable), already-
49+
* swept (recoverable), `CURRENT`, and `UNKNOWN_SIGNER` rows are all left alone.
50+
*/
51+
export function selectPendingRecoveryOutpoints(
52+
contractsWithVtxos: ReadonlyArray<{
53+
contract: { params: { serverPubKey?: string } };
54+
vtxos: ReadonlyArray<VirtualCoin>;
55+
}>,
56+
signerSet: SignerSet,
57+
nowSeconds: number = Math.floor(Date.now() / 1000),
58+
): Set<string> {
59+
const out = new Set<string>();
60+
for (const { contract, vtxos } of contractsWithVtxos) {
61+
const serverPubKey = contract.params.serverPubKey;
62+
if (!serverPubKey) continue;
63+
if (classifyAgainstSignerSet(serverPubKey, signerSet, nowSeconds).status !== "EXPIRED") {
64+
continue;
65+
}
66+
for (const v of vtxos) {
67+
if (isSpendable(v) && !isRecoverable(v)) out.add(`${v.txid}:${v.vout}`);
68+
}
69+
}
70+
return out;
71+
}
72+
3773
/**
3874
* Extended wallet interface for boarding input sweep operations.
3975
* These properties exist on the concrete Wallet class but not on IWallet.

‎packages/ts-sdk/src/wallet/wallet.ts‎

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
MAX_VTXOS_PER_SETTLEMENT,
6464
SettlementConfig,
6565
VtxoManager,
66+
selectPendingRecoveryOutpoints,
6667
} from "./vtxo-manager";
6768
import { ArkNote } from "../arknote";
6869
import { 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>();
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from "vitest";
2+
import { selectPendingRecoveryOutpoints } from "../src/wallet/vtxo-manager";
3+
import type { SignerSet } from "../src/wallet/signerRotation";
4+
5+
const NOW = 1_700_000_000;
6+
const ACTIVE = "aa".repeat(32);
7+
const EXPIRED_SIGNER = "bb".repeat(32);
8+
const MIGRATABLE_SIGNER = "cc".repeat(32);
9+
const DUE_NOW_SIGNER = "dd".repeat(32);
10+
const UNKNOWN_SIGNER = "ee".repeat(32);
11+
12+
const signerSet: SignerSet = {
13+
active: ACTIVE,
14+
deprecated: new Map([
15+
[EXPIRED_SIGNER, BigInt(NOW - 100)], // cutoff passed → EXPIRED
16+
[MIGRATABLE_SIGNER, BigInt(NOW + 10_000)], // cutoff ahead → MIGRATABLE
17+
[DUE_NOW_SIGNER, 0n], // no cutoff → DUE_NOW
18+
]),
19+
};
20+
21+
// Minimal VirtualCoin-shaped stub: selectPendingRecoveryOutpoints only reads
22+
// txid/vout (outpoint), isSpent (isSpendable), and virtualStatus.state (swept).
23+
const vtxo = (txid: string, vout: number, state: string, isSpent = false) =>
24+
({ txid, vout, value: 1000, isSpent, virtualStatus: { state } }) as never;
25+
26+
const row = (serverPubKey: string, vtxos: unknown[]) =>
27+
({ contract: { params: { serverPubKey } }, vtxos }) as never;
28+
29+
describe("selectPendingRecoveryOutpoints", () => {
30+
it("returns only EXPIRED-signer VTXOs that are spendable but not yet swept", () => {
31+
const out = selectPendingRecoveryOutpoints(
32+
[
33+
row(ACTIVE, [vtxo("active", 0, "settled")]), // CURRENT → no
34+
row(EXPIRED_SIGNER, [vtxo("exp-settled", 0, "settled")]), // EXPIRED awaiting sweep → YES
35+
row(EXPIRED_SIGNER, [vtxo("exp-swept", 0, "swept")]), // EXPIRED swept → no (recoverable)
36+
row(EXPIRED_SIGNER, [vtxo("exp-spent", 1, "settled", true)]), // spent → no (!spendable)
37+
row(MIGRATABLE_SIGNER, [vtxo("mig", 0, "settled")]), // MIGRATABLE → still spendable
38+
row(DUE_NOW_SIGNER, [vtxo("due", 0, "settled")]), // DUE_NOW → still spendable
39+
row(UNKNOWN_SIGNER, [vtxo("unk", 0, "settled")]), // UNKNOWN_SIGNER → untouched
40+
],
41+
signerSet,
42+
NOW,
43+
);
44+
expect([...out]).toEqual(["exp-settled:0"]);
45+
});
46+
47+
it("is empty when there are no deprecated signers", () => {
48+
const out = selectPendingRecoveryOutpoints(
49+
[row(ACTIVE, [vtxo("a", 0, "settled")])],
50+
{ active: ACTIVE, deprecated: new Map() },
51+
NOW,
52+
);
53+
expect(out.size).toBe(0);
54+
});
55+
});

‎packages/ts-sdk/test/wallet-message-handler.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,7 @@ describe("WalletMessageHandler repo-backed reads", () => {
13891389
commitmentsToIgnore: new Set(),
13901390
}),
13911391
dustAmount: 546n,
1392+
pendingRecoveryOutpoints: vi.fn().mockResolvedValue(new Set<string>()),
13921393
getContractManager: vi.fn().mockResolvedValue({
13931394
getContracts: vi.fn().mockResolvedValue(contracts),
13941395
onContractEvent: vi.fn().mockReturnValue(vi.fn()),
@@ -1490,6 +1491,43 @@ describe("WalletMessageHandler repo-backed reads", () => {
14901491
});
14911492
});
14921493

1494+
it("GET_BALANCE excludes pending-recovery (past-cutoff) VTXOs from available", async () => {
1495+
setupHandler();
1496+
const settled = createMockExtendedVtxo({
1497+
txid: "aa".repeat(32),
1498+
value: 100000,
1499+
virtualStatus: { state: "settled" },
1500+
});
1501+
const pendingExpired = createMockExtendedVtxo({
1502+
txid: "cc".repeat(32),
1503+
value: 70000,
1504+
virtualStatus: { state: "settled" },
1505+
});
1506+
await walletRepo.saveVtxos(TEST_DEFAULT_ARK_ADDRESS, [settled, pendingExpired]);
1507+
1508+
// The wallet reports the past-cutoff (EXPIRED) VTXO as pending recovery.
1509+
(updater as any).readonlyWallet.pendingRecoveryOutpoints = vi
1510+
.fn()
1511+
.mockResolvedValue(new Set([`${pendingExpired.txid}:${pendingExpired.vout}`]));
1512+
1513+
const response = await updater.handleMessage({
1514+
...baseMessage(),
1515+
type: "GET_BALANCE",
1516+
} as any);
1517+
1518+
// Excluded from the spendable buckets, surfaced under pendingRecovery,
1519+
// still counted in total.
1520+
expect(response).toMatchObject({
1521+
type: "BALANCE",
1522+
payload: {
1523+
settled: 100000,
1524+
available: 100000,
1525+
pendingRecovery: 70000,
1526+
total: 170000,
1527+
},
1528+
});
1529+
});
1530+
14931531
it("GET_TRANSACTION_HISTORY reads from repository, not indexer", async () => {
14941532
setupHandler();
14951533
const vtxo = createMockExtendedVtxo({

0 commit comments

Comments
 (0)