From 490936f81186d53ee701b2e176ff5bd6f8bf409c Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Fri, 28 Aug 2026 18:52:41 +0000 Subject: [PATCH] feat(sdk-core): support stuck transaction age filters Forward optional unconfirmed age filters through the wallet stuck transaction API. This lets callers avoid treating newly broadcast transactions as stuck without constructing query strings, while preserving opt-in behavior. Ticket: WCN-2443 Session-Id: 692d7cfe-5c58-429c-9202-245a2baf6534 Task-Id: 4b9b5bb9-b30d-415e-ac5f-9f15f753c9a2 --- modules/sdk-core/src/bitgo/wallet/iWallet.ts | 10 +++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 20 ++++++ .../unit/bitgo/wallet/getPotentialStuckTxs.ts | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 modules/sdk-core/test/unit/bitgo/wallet/getPotentialStuckTxs.ts diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 06fca6da8a..a7d501ad94 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -467,6 +467,15 @@ export interface GetTransactionOptions extends PaginationOptions { includeRbf?: boolean; } +export interface GetPotentialStuckTxsOptions { + /** Only return transactions unconfirmed longer than this many minutes. */ + minUnconfirmedMinutes?: number; + /** Only return transactions unconfirmed longer than this many blocks. */ + minUnconfirmedBlocks?: number; + expandSendTransferMetadata?: boolean; + txid?: string; +} + export interface TransfersOptions extends PaginationOptions { txHash?: string; allTokens?: boolean; @@ -1198,6 +1207,7 @@ export interface IWallet { pendingApprovals(): IPendingApproval[]; refresh(params?: Record): Promise; transactions(params?: PaginationOptions): Promise; + getPotentialStuckTxs(params?: GetPotentialStuckTxsOptions): Promise; getTransaction(params?: GetTransactionOptions): Promise; transfers(params?: TransfersOptions): Promise; getTransfer(params?: GetTransferOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 4618486c50..845eca230a 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -96,6 +96,7 @@ import { FundForwardersOptions, GetAddressOptions, GetPrvOptions, + GetPotentialStuckTxsOptions, GetTransactionOptions, GetTransferOptions, GetUserPrvOptions, @@ -501,6 +502,25 @@ export class Wallet implements IWallet { .result(); } + /** + * List potentially stuck transactions for this wallet. + * Age thresholds are opt-in; when both are supplied, the API uses OR semantics. + */ + async getPotentialStuckTxs(params: GetPotentialStuckTxsOptions = {}): Promise { + const query: GetPotentialStuckTxsOptions = {}; + if (params.minUnconfirmedMinutes !== undefined) query.minUnconfirmedMinutes = params.minUnconfirmedMinutes; + if (params.minUnconfirmedBlocks !== undefined) query.minUnconfirmedBlocks = params.minUnconfirmedBlocks; + if (params.expandSendTransferMetadata !== undefined) { + query.expandSendTransferMetadata = params.expandSendTransferMetadata; + } + if (params.txid !== undefined) query.txid = params.txid; + + return this.bitgo + .get(this.baseCoin.url('/wallet/' + this._wallet.id + '/potentialStuckTxs')) + .query(query) + .result(); + } + /** * Return a list of nft tokens for this wallet. Will always return undefined if the wallet * was not initialized with the allTokens flag. diff --git a/modules/sdk-core/test/unit/bitgo/wallet/getPotentialStuckTxs.ts b/modules/sdk-core/test/unit/bitgo/wallet/getPotentialStuckTxs.ts new file mode 100644 index 0000000000..93da294653 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/getPotentialStuckTxs.ts @@ -0,0 +1,68 @@ +import * as sinon from 'sinon'; +import 'should'; +import { Wallet } from '../../../../src'; + +describe('Wallet - getPotentialStuckTxs', function () { + let wallet: Wallet; + let mockBitGo: any; + let mockBaseCoin: any; + + beforeEach(function () { + mockBitGo = { get: sinon.stub() }; + mockBaseCoin = { + url: sinon.stub().returns('/api/v2/btc'), + supportsTss: sinon.stub().returns(false), + }; + wallet = new Wallet(mockBitGo, mockBaseCoin, { + id: 'test-wallet-id', + keys: ['user-key', 'backup-key', 'bitgo-key'], + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + function stubGet() { + const response = [{ txId: 'tx-id' }]; + const resultStub = sinon.stub().resolves(response); + const queryStub = sinon.stub().returns({ result: resultStub }); + mockBitGo.get.returns({ query: queryStub }); + return { response, queryStub }; + } + + it('does not add age filters when no options are supplied', async function () { + const { response, queryStub } = stubGet(); + + const result = await wallet.getPotentialStuckTxs(); + + result.should.deepEqual(response); + sinon.assert.calledWith(mockBitGo.get, '/api/v2/btc'); + sinon.assert.calledWith(mockBaseCoin.url, '/wallet/test-wallet-id/potentialStuckTxs'); + sinon.assert.calledWith(queryStub, {}); + }); + + it('forwards the minimum unconfirmed minutes filter', async function () { + const { queryStub } = stubGet(); + + await wallet.getPotentialStuckTxs({ minUnconfirmedMinutes: 30 }); + + sinon.assert.calledWith(queryStub, { minUnconfirmedMinutes: 30 }); + }); + + it('forwards the minimum unconfirmed blocks filter', async function () { + const { queryStub } = stubGet(); + + await wallet.getPotentialStuckTxs({ minUnconfirmedBlocks: 6 }); + + sinon.assert.calledWith(queryStub, { minUnconfirmedBlocks: 6 }); + }); + + it('forwards both age filters together', async function () { + const { queryStub } = stubGet(); + + await wallet.getPotentialStuckTxs({ minUnconfirmedMinutes: 30, minUnconfirmedBlocks: 6 }); + + sinon.assert.calledWith(queryStub, { minUnconfirmedMinutes: 30, minUnconfirmedBlocks: 6 }); + }); +});