From a656b4526493c7bb40d94426145b4df67e46c22c Mon Sep 17 00:00:00 2001 From: Support Bot Date: Wed, 1 Jul 2026 14:58:58 +0000 Subject: [PATCH] feat(sdk-core): add freeze/unfreeze unspent methods and frozen filter Add freezeUnspent() and unfreezeUnspent() methods to the Wallet class and IWallet interface. These methods call the BitGo API to freeze or unfreeze a specific UTXO by its ID (txid:vout format), preventing or allowing it to be selected during transaction building. Also add a frozen filter to UnspentsOptions so callers can list only frozen or non-frozen unspents via the existing unspents() method. Why: UTXOs can become frozen on BitGo's platform (e.g. the ZEC unspent 52d9e671...:0 reported in T1-3661). The SDK previously had no way to inspect or manage the frozen state of individual UTXOs programmatically, leaving operators with no self-service path to unfreeze stuck funds. Ticket: T1-3662 Session-Id: efcc06e2-ce7b-46e0-a938-bf99174ce650 Task-Id: 267141cf-1bb3-4cad-ae90-bd7ed76a54d0 --- modules/bitgo/test/v2/unit/unspents.ts | 78 ++++++++++++++++++++ modules/sdk-core/src/bitgo/wallet/iWallet.ts | 11 +++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 29 ++++++++ 3 files changed, 118 insertions(+) diff --git a/modules/bitgo/test/v2/unit/unspents.ts b/modules/bitgo/test/v2/unit/unspents.ts index 0d2ed310d6..28ea7fe5fa 100644 --- a/modules/bitgo/test/v2/unit/unspents.ts +++ b/modules/bitgo/test/v2/unit/unspents.ts @@ -1,5 +1,6 @@ import nock = require('nock'); import * as sinon from 'sinon'; +import * as assert from 'assert'; import { common, Wallet } from '@bitgo/sdk-core'; import { TestBitGo } from '@bitgo/sdk-test'; import { BitGo } from '../../../src'; @@ -201,4 +202,81 @@ describe('Verify string type is used for value of unspent', function () { deleteScope.done(); }); }); + + describe('Frozen Unspents', function () { + after(nock.cleanAll); + + const unspentId = 'abc123def456abc123def456abc123def456abc123def456abc123def456abc123:0'; + + it('should list frozen unspents by passing frozen=true query param', async function () { + const scope = nock(bgUrl) + .get(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/unspents`) + .query({ frozen: 'true' }) + .reply(200, { unspents: [], count: 0 }); + + await wallet.unspents({ frozen: true }); + + scope.done(); + }); + + it('should list non-frozen unspents by passing frozen=false query param', async function () { + const scope = nock(bgUrl) + .get(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/unspents`) + .query({ frozen: 'false' }) + .reply(200, { unspents: [], count: 0 }); + + await wallet.unspents({ frozen: false }); + + scope.done(); + }); + + it('should not include frozen param when not specified', async function () { + const scope = nock(bgUrl) + .get(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/unspents`) + .query({}) + .reply(200, { unspents: [], count: 0 }); + + await wallet.unspents({}); + + scope.done(); + }); + + it('should freeze an unspent', async function () { + const scope = nock(bgUrl) + .post( + `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/unspents/${encodeURIComponent(unspentId)}/freeze` + ) + .reply(200, { id: unspentId, frozen: true }); + + await wallet.freezeUnspent({ unspentId }); + + scope.done(); + }); + + it('should unfreeze an unspent', async function () { + const scope = nock(bgUrl) + .delete( + `/api/v2/${wallet.coin()}/wallet/${wallet.id()}/unspents/${encodeURIComponent(unspentId)}/freeze` + ) + .reply(200, { id: unspentId, frozen: false }); + + await wallet.unfreezeUnspent({ unspentId }); + + scope.done(); + }); + + it('should throw when freezeUnspent is called without unspentId', async function () { + await assert.rejects( + () => wallet.freezeUnspent({ unspentId: '' }), + { message: 'unspentId is required' } + ); + }); + + it('should throw when unfreezeUnspent is called without unspentId', async function () { + await assert.rejects( + () => wallet.unfreezeUnspent({ unspentId: '' }), + { message: 'unspentId is required' } + ); + }); + }); }); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 3681c535f3..adede31a27 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -493,6 +493,15 @@ export interface UnspentsOptions extends PaginationOptions { segwit?: boolean; chains?: number[]; unspentIds?: string[]; + frozen?: boolean; +} + +export interface FreezeUnspentOptions { + unspentId: string; +} + +export interface UnfreezeUnspentOptions { + unspentId: string; } export interface ManageUnspentReservationOptions { @@ -1141,6 +1150,8 @@ export interface IWallet { transferBySequenceId(params?: TransferBySequenceIdOptions): Promise; maximumSpendable(params?: MaximumSpendableOptions): Promise; unspents(params?: UnspentsOptions): Promise; + freezeUnspent(params: FreezeUnspentOptions): Promise; + unfreezeUnspent(params: UnfreezeUnspentOptions): Promise; consolidateUnspents(params?: ConsolidateUnspentsOptions): Promise; fanoutUnspents(params?: FanoutUnspentsOptions): Promise; updateTokenFlushThresholds(thresholds?: any): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 870d8f2a9e..7d26eb9a6f 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -92,6 +92,8 @@ import { ForwarderBalance, ForwarderBalanceOptions, FreezeOptions, + FreezeUnspentOptions, + UnfreezeUnspentOptions, FundForwarderParams, FundForwardersOptions, GetAddressOptions, @@ -742,6 +744,7 @@ export class Wallet implements IWallet { async unspents(params: UnspentsOptions = {}): Promise { const query = _.pick(params, [ 'chains', + 'frozen', 'limit', 'maxValue', 'minConfirms', @@ -756,6 +759,32 @@ export class Wallet implements IWallet { return this.bitgo.get(this.url('/unspents')).query(query).result(); } + /** + * Freeze a specific unspent, preventing it from being selected during transaction building. + * @param params + * @param params.unspentId - the ID of the unspent to freeze (format: txid:vout) + * @returns {*} + */ + async freezeUnspent(params: FreezeUnspentOptions): Promise { + if (!params.unspentId) { + throw new Error('unspentId is required'); + } + return this.bitgo.post(this.url(`/unspents/${encodeURIComponent(params.unspentId)}/freeze`)).result(); + } + + /** + * Unfreeze a previously frozen unspent, allowing it to be selected during transaction building. + * @param params + * @param params.unspentId - the ID of the unspent to unfreeze (format: txid:vout) + * @returns {*} + */ + async unfreezeUnspent(params: UnfreezeUnspentOptions): Promise { + if (!params.unspentId) { + throw new Error('unspentId is required'); + } + return this.bitgo.del(this.url(`/unspents/${encodeURIComponent(params.unspentId)}/freeze`)).result(); + } + /** * Consolidate or fanout unspents on a wallet *