From ff0cbb70dbeea56c357d4b44800c12a309888d91 Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Mon, 15 Jun 2026 17:25:24 +0000 Subject: [PATCH] feat(sdk-api): pass recipient addresses to v1 billing fee endpoint Forward recipient addresses from the SDK to the server's GET /api/v1/wallet/:id/billing/fee endpoint as recipients[] query params. This allows the server to waive the PayGo fee when all recipients are v2 PayGo wallets (v1-to-v2 migration scenario). Backward-compatible: existing calls without recipients are unchanged. Ticket: T1-3579 Session-Id: 9bcb0f58-d633-492c-a7a4-70bcd09ce08b Task-Id: bf7868aa-6a4c-4ee7-8520-1e6b61551a64 --- modules/sdk-api/src/v1/transactionBuilder.ts | 20 ++++-- modules/sdk-api/src/v1/wallet.ts | 9 ++- modules/sdk-api/test/unit/v1/wallet.ts | 67 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/modules/sdk-api/src/v1/transactionBuilder.ts b/modules/sdk-api/src/v1/transactionBuilder.ts index 9bfca327af..e819d76831 100644 --- a/modules/sdk-api/src/v1/transactionBuilder.ts +++ b/modules/sdk-api/src/v1/transactionBuilder.ts @@ -288,13 +288,19 @@ exports.createTransaction = function (params) { if (bitgoFeeInfo) { return; } - return params.wallet.getBitGoFee({ amount: totalOutputAmount, instant: params.instant }).then(function (result) { - if (result && result.fee > 0) { - bitgoFeeInfo = { - amount: result.fee, - }; - } - }); + return params.wallet + .getBitGoFee({ + amount: totalOutputAmount, + instant: params.instant, + recipients: params.recipients?.map((r: any) => r.address).filter(Boolean) ?? [], + }) + .then(function (result) { + if (result && result.fee > 0) { + bitgoFeeInfo = { + amount: result.fee, + }; + } + }); }).then(function () { if (bitgoFeeInfo && bitgoFeeInfo.amount > 0) { totalAmount += bitgoFeeInfo.amount; diff --git a/modules/sdk-api/src/v1/wallet.ts b/modules/sdk-api/src/v1/wallet.ts index 59405802fb..c25dab1e8d 100644 --- a/modules/sdk-api/src/v1/wallet.ts +++ b/modules/sdk-api/src/v1/wallet.ts @@ -2555,9 +2555,12 @@ Wallet.prototype.getBitGoFee = function (params, callback) { if (params.instant && !_.isBoolean(params.instant)) { throw new Error('invalid instant argument'); } - return Promise.resolve(this.bitgo.get(this.url('/billing/fee')).query(params).result()) - .then(callback) - .catch(callback); + const { recipients, ...baseParams } = params; + let req = this.bitgo.get(this.url('/billing/fee')).query(baseParams); + if (Array.isArray(recipients) && recipients.length > 0) { + req = req.query({ 'recipients[]': recipients }); + } + return Promise.resolve(req.result()).then(callback).catch(callback); }; /* diff --git a/modules/sdk-api/test/unit/v1/wallet.ts b/modules/sdk-api/test/unit/v1/wallet.ts index 84c24425b4..5836e39080 100644 --- a/modules/sdk-api/test/unit/v1/wallet.ts +++ b/modules/sdk-api/test/unit/v1/wallet.ts @@ -1823,4 +1823,71 @@ describe('Wallet Prototype Methods', function () { }); }); }); + + describe('getBitGoFee', function () { + let bgUrl: string; + let wallet: any; + + before(function () { + nock.pendingMocks().should.be.empty(); + const prodBitgo = new BitGoAPI({ env: 'prod', clientConstants: { constants: {} } }); + bgUrl = common.Environments[prodBitgo.getEnv()].uri; + wallet = new Wallet(prodBitgo, { + id: '2NCoSfHH6Ls4CdTS5QahgC9k7x9RfXeSwY4', + private: { keychains: [userKeypair, backupKeypair, bitgoKey] }, + }); + }); + + afterEach(function () { + nock.cleanAll(); + }); + + it('sends amount and instant without recipients when recipients array is empty', async function () { + const scope = nock(bgUrl) + .get(`/api/v1/wallet/${wallet.id()}/billing/fee`) + .query({ amount: '100000', instant: 'false' }) + .reply(200, { fee: 1000 }); + + const result = await wallet.getBitGoFee({ amount: 100000, instant: false }); + result.fee.should.equal(1000); + scope.isDone().should.be.true(); + }); + + it('sends recipients[] query params when recipients are provided', async function () { + const addr1 = '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'; + const addr2 = '3FZbgi29cpjq2GjdwV8eyHuJJnkLtktZc5'; + + const scope = nock(bgUrl) + .get(`/api/v1/wallet/${wallet.id()}/billing/fee`) + .query((query) => { + const recipientsParam = query['recipients[]']; + const recipientsList = Array.isArray(recipientsParam) ? recipientsParam : [recipientsParam]; + return query.amount === '100000' && recipientsList.includes(addr1) && recipientsList.includes(addr2); + }) + .reply(200, { fee: 0 }); + + const result = await wallet.getBitGoFee({ amount: 100000, recipients: [addr1, addr2] }); + result.fee.should.equal(0); + scope.isDone().should.be.true(); + }); + + it('omits recipients[] when recipients array is empty', async function () { + const scope = nock(bgUrl) + .get(`/api/v1/wallet/${wallet.id()}/billing/fee`) + .query((query) => query.amount === '200000' && !('recipients[]' in query)) + .reply(200, { fee: 500 }); + + const result = await wallet.getBitGoFee({ amount: 200000, recipients: [] }); + result.fee.should.equal(500); + scope.isDone().should.be.true(); + }); + + it('throws when amount is not a number', function () { + (() => wallet.getBitGoFee({ amount: 'bad' })).should.throw('invalid amount argument'); + }); + + it('throws when instant is not a boolean', function () { + (() => wallet.getBitGoFee({ amount: 100, instant: 'yes' })).should.throw('invalid instant argument'); + }); + }); });