From 99b76f4753f4e05669a2e1f6ba70a4328a046b50 Mon Sep 17 00:00:00 2001 From: Blake Byrnes Date: Sun, 8 Mar 2026 19:16:26 -0400 Subject: [PATCH] fix(vault): prevent collect timing race in pending cosigns Make pending cosign updates atomic and discard stale async callback results so older subscription emissions cannot overwrite newer collect state. This keeps collect deadline and pending cosign state consistent during rapid chain updates. --- src-vue/lib/MyVault.ts | 82 ++++++++++++++----- .../VaultCollectOverlay.vue | 5 +- .../vaulting-screen/Dashboard.vue | 5 +- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src-vue/lib/MyVault.ts b/src-vue/lib/MyVault.ts index 38980349..8eb2c1dd 100644 --- a/src-vue/lib/MyVault.ts +++ b/src-vue/lib/MyVault.ts @@ -56,6 +56,10 @@ type ICollectOrphanCosignMetadata = { vout: number; vaultSignatureHex: string; }; +type IPendingCosignUtxo = { + marketValue: bigint; + dueFrame?: number; +}; export class MyVault { public data: { @@ -64,7 +68,7 @@ export class MyVault { metadata: IVaultRecord | null; stats: IVaultStats | null; pendingCollectRevenue: bigint; - pendingCosignUtxosById: Map; + pendingCosignUtxosById: Map; nextCollectDueDate: number; expiringCollectAmount: bigint; finalizeMyBitcoinError?: { lockUtxoId: number; error: string }; @@ -113,6 +117,8 @@ export class MyVault { #singleActiveBitcoinQueue = new SingleFileQueue(); // Serialize cosign submissions (collect + individual cosign) and track in-flight intent per UTXO. #cosignQueue = new SingleFileQueue(); + #collectFrames: { frameId: number; uncollectedEarnings: bigint }[] = []; + #pendingCosignUpdateSeq = 0; constructor( private readonly dbPromise: Promise, @@ -167,6 +173,7 @@ export class MyVault { if (this.#waitForLoad && !reload) return this.#waitForLoad.promise; this.#waitForLoad = createDeferred(); + this.#collectFrames = []; try { console.log('Loading MyVault...'); await this.miningFrames.load(); @@ -261,24 +268,26 @@ export class MyVault { const client = await getMainchainClient(false); // update stats live - const sub = await client.query.vaults.vaultsById(vaultId, async vault => { + const sub = await client.query.vaults.vaultsById(vaultId, vault => { if (vault.isSome) this.createdVault?.load(vault.unwrap()); - await this.updateRevenueStats(); }); const sub2 = await client.query.vaults.revenuePerFrameByVault(vaultId, async x => { await this.updateRevenueStats(x); - await this.updateCollectDueDate(); + this.updateCollectDueDate(); }); - const sub3 = await client.query.vaults.pendingCosignByVaultId(vaultId, x => this.recordPendingCosignUtxos(x)); - const sub4 = await client.query.vaults.lastCollectFrameByVaultId(vaultId, x => - this.updateCollectDueDate(x.isSome ? x.unwrap().toNumber() : undefined), - ); + const sub3 = await client.query.vaults.pendingCosignByVaultId(vaultId, async x => { + const updateSeq = ++this.#pendingCosignUpdateSeq; + await this.recordPendingCosignUtxos(x, updateSeq); + }); + const sub4 = await client.query.vaults.lastCollectFrameByVaultId(vaultId, () => { + this.updateCollectDueDate(); + }); const sub5 = await client.query.miningSlot.nextFrameId(frameId => { this.data.currentFrameId = frameId.toNumber() - 1; - void this.updateCollectDueDate(); + this.updateCollectDueDate(); }); const sub6 = await this.subscribeToTreasuryAllocated(); @@ -286,12 +295,12 @@ export class MyVault { this.#subscriptions.push(sub, sub2, sub3, sub4, sub5, sub6); } - private async updateCollectDueDate(_lastCollectFrameId?: number) { + private updateCollectDueDate() { const framesToCollect = this.#configs!.timeToCollectFrames; let nextCollectFrame = this.data.currentFrameId + framesToCollect; this.data.expiringCollectAmount = 0n; const oldestToCollectFrame = this.data.currentFrameId - framesToCollect; - for (const frameChange of this.data.stats?.changesByFrame ?? []) { + for (const frameChange of this.#collectFrames) { if (frameChange.uncollectedEarnings > 0n) { this.data.expiringCollectAmount = frameChange.uncollectedEarnings; // descending order @@ -299,19 +308,46 @@ export class MyVault { } if (frameChange.frameId < oldestToCollectFrame) break; } + if (this.data.pendingCosignUtxosById.size > 0) { + let earliestCosignDueFrame = Number.MAX_SAFE_INTEGER; + for (const pendingCosign of this.data.pendingCosignUtxosById.values()) { + if (pendingCosign.dueFrame === undefined) { + continue; + } + earliestCosignDueFrame = Math.min(earliestCosignDueFrame, pendingCosign.dueFrame); + } + if (earliestCosignDueFrame < Number.MAX_SAFE_INTEGER) { + nextCollectFrame = Math.min(nextCollectFrame, earliestCosignDueFrame); + } + } nextCollectFrame = Math.max(this.data.currentFrameId + 1, nextCollectFrame); this.data.nextCollectDueDate = this.miningFrames.getFrameDate(nextCollectFrame).getTime(); } - private async recordPendingCosignUtxos(rawUtxoIds: Iterable) { - this.data.pendingCosignUtxosById.clear(); + private async recordPendingCosignUtxos(rawUtxoIds: Iterable, updateSeq: number) { + const previousPendingCosignsById = new Map(this.data.pendingCosignUtxosById); + const pendingCosignUtxosById = new Map(); const client = await getMainchainClient(false); for (const utxoId of rawUtxoIds) { const id = utxoId.toNumber(); const lock = await BitcoinLock.get(client, id); - this.data.pendingCosignUtxosById.set(id, lock?.lockedMarketRate ?? 0n); + const previousPending = previousPendingCosignsById.get(id); + const pendingReleaseRaw = await client.query.bitcoinLocks.lockReleaseRequestsByUtxoId(id); + const dueFrame = pendingReleaseRaw.isSome + ? pendingReleaseRaw.unwrap().cosignDueFrame.toNumber() + : previousPending?.dueFrame; + const marketValue = lock?.lockedMarketRate ?? previousPending?.marketValue ?? 0n; + if (marketValue === 0n && !lock && previousPending?.marketValue === undefined) { + console.warn(`Pending cosign UTXO ${id} has no lock data; using 0 as fallback market value.`); + } + pendingCosignUtxosById.set(id, { marketValue, dueFrame }); + } + if (updateSeq !== this.#pendingCosignUpdateSeq) { + return; } + this.data.pendingCosignUtxosById = pendingCosignUtxosById; + this.updateCollectDueDate(); } public async cosignMyLock( @@ -560,7 +596,7 @@ export class MyVault { if (!this.metadata) { throw new Error('No metadata available to collect revenue'); } - const toCosign = [...this.data.pendingCosignUtxosById]; + const toCosign = [...this.data.pendingCosignUtxosById.keys()]; const cosignedUtxoIds: number[] = []; const cosignedOrphanUtxos: ICollectOrphanCosignMetadata[] = []; const expectedCollectRevenue = this.data.pendingCollectRevenue; @@ -570,7 +606,7 @@ export class MyVault { const argonKeyring = await this.walletKeys.getVaultingKeypair(); const txs: SubmittableExtrinsic[] = []; try { - for (const [utxoId, _amount] of toCosign) { + for (const utxoId of toCosign) { if (this.findPendingCosignTxInfo(utxoId)) { continue; } @@ -931,13 +967,19 @@ export class MyVault { const client = await getMainchainClient(false); const vaultId = this.createdVault.vaultId; frameRevenues ??= await client.query.vaults.revenuePerFrameByVault(vaultId); + this.#collectFrames = frameRevenues + .map(frameRevenue => ({ + frameId: frameRevenue.frameId.toNumber(), + uncollectedEarnings: frameRevenue.uncollectedRevenue.toBigInt(), + })) + .sort((a, b) => b.frameId - a.frameId); this.vaults.vaultsById[vaultId] = this.createdVault; await this.vaults.updateVaultRevenue(vaultId, frameRevenues); - this.data.pendingCollectRevenue = 0n; - for (const frameRevenue of frameRevenues) { - this.data.pendingCollectRevenue += frameRevenue.uncollectedRevenue.toBigInt(); - } + this.data.pendingCollectRevenue = this.#collectFrames.reduce( + (total, frame) => total + frame.uncollectedEarnings, + 0n, + ); const data = this.vaults.stats?.vaultsById?.[vaultId]; if (data) { this.data.stats = { ...data }; diff --git a/src-vue/overlays-operations/VaultCollectOverlay.vue b/src-vue/overlays-operations/VaultCollectOverlay.vue index 854e3679..d52cf702 100644 --- a/src-vue/overlays-operations/VaultCollectOverlay.vue +++ b/src-vue/overlays-operations/VaultCollectOverlay.vue @@ -161,7 +161,10 @@ const collectRevenue = Vue.ref(myVault.data.pendingCollectRevenue); const { microgonToMoneyNm } = createNumeralHelpers(currency); const pendingCosignSum = Vue.computed(() => { - const sum = Array.from(myVault.data.pendingCosignUtxosById.values()).reduce((acc, utxo) => acc + utxo, 0n); + const sum = Array.from(myVault.data.pendingCosignUtxosById.values()).reduce( + (acc, utxo) => acc + utxo.marketValue, + 0n, + ); return bigIntMin(sum, myVault.createdVault?.securitization ?? 0n); }); diff --git a/src-vue/screens-operations/vaulting-screen/Dashboard.vue b/src-vue/screens-operations/vaulting-screen/Dashboard.vue index 42a2c951..29351e5b 100644 --- a/src-vue/screens-operations/vaulting-screen/Dashboard.vue +++ b/src-vue/screens-operations/vaulting-screen/Dashboard.vue @@ -458,7 +458,10 @@ const revenueMicrogons = Vue.computed(() => { }); const pendingCosignPenalty = Vue.computed(() => { - const sum = Array.from(myVault.data.pendingCosignUtxosById.values()).reduce((acc, amount) => acc + amount, 0n); + const sum = Array.from(myVault.data.pendingCosignUtxosById.values()).reduce( + (acc, entry) => acc + entry.marketValue, + 0n, + ); return bigIntMin(sum, myVault.createdVault?.securitization ?? 0n); });