Skip to content

Commit 78ff2b3

Browse files
authored
Merge pull request #7447 from BitGo/WP-6658-type-validation-fanout-unspents-v2
fix(express): fanoutUnspentsV2 type codec
2 parents 6a465ae + fc6bb6d commit 78ff2b3

File tree

2 files changed

+87
-0
lines changed

2 files changed

+87
-0
lines changed

modules/express/src/typedRoutes/api/v2/fanoutUnspents.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export const FanoutUnspentsRequestBody = {
5454
otp: optional(t.string),
5555
/** Target address for the fanout outputs */
5656
targetAddress: optional(t.string),
57+
/** Transaction format type (e.g., 'legacy', 'psbt', 'psbt-lite') - controls output format */
58+
txFormat: optional(t.union([t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')])),
5759
/** If true, enables fanout of large number of unspents by creating multiple transactions (200 unspents per tx) */
5860
bulk: optional(t.boolean),
5961
} as const;

modules/express/test/unit/typedRoutes/fanoutUnspentsV2.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,39 @@ describe('FanoutUnspents V2 codec tests', function () {
207207
assert.deepStrictEqual(callArgs.unspents, requestBody.unspents);
208208
});
209209

210+
it('should successfully fanout unspents with txFormat parameter', async function () {
211+
const requestBody = {
212+
numUnspentsToMake: 10,
213+
walletPassphrase: 'test_passphrase',
214+
txFormat: 'psbt' as const,
215+
};
216+
217+
const mockWallet = {
218+
fanoutUnspents: sinon.stub().resolves(mockFanoutResponse),
219+
};
220+
221+
const walletsGetStub = sinon.stub().resolves(mockWallet);
222+
const mockCoin = {
223+
wallets: sinon.stub().returns({ get: walletsGetStub }),
224+
};
225+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
226+
227+
const result = await agent
228+
.post(`/api/v2/${coin}/wallet/${walletId}/fanoutunspents`)
229+
.set('Authorization', 'Bearer test_access_token_12345')
230+
.set('Content-Type', 'application/json')
231+
.send(requestBody);
232+
233+
assert.strictEqual(result.status, 200);
234+
235+
const decodedResponse = assertSingleTxResponse(assertDecode(FanoutUnspentsResponse, result.body));
236+
assert.strictEqual(decodedResponse.status, mockFanoutResponse.status);
237+
238+
// Verify txFormat was passed through to SDK
239+
const callArgs = mockWallet.fanoutUnspents.firstCall.args[0];
240+
assert.strictEqual(callArgs.txFormat, 'psbt');
241+
});
242+
210243
it('should return instant transaction response', async function () {
211244
const requestBody = {
212245
numUnspentsToMake: 10,
@@ -639,6 +672,7 @@ describe('FanoutUnspents V2 codec tests', function () {
639672
comment: 'Test fanout',
640673
otp: '123456',
641674
targetAddress: '2N8hwP1WmJrFF5QWABn38y63uYLhnJYJYTF',
675+
txFormat: 'psbt' as const,
642676
unspents: ['abc:0', 'def:1'],
643677
bulk: true,
644678
};
@@ -648,6 +682,7 @@ describe('FanoutUnspents V2 codec tests', function () {
648682
assert.strictEqual(decoded.numUnspentsToMake, validBody.numUnspentsToMake);
649683
assert.strictEqual(decoded.minConfirms, validBody.minConfirms);
650684
assert.strictEqual(decoded.maxNumInputsToUse, validBody.maxNumInputsToUse);
685+
assert.strictEqual(decoded.txFormat, 'psbt');
651686
assert.deepStrictEqual(decoded.unspents, validBody.unspents);
652687
assert.strictEqual(decoded.bulk, true);
653688
});
@@ -706,6 +741,56 @@ describe('FanoutUnspents V2 codec tests', function () {
706741
const decodedString = assertDecode(t.type(FanoutUnspentsRequestBody), validBodyString);
707742
assert.strictEqual(decodedString.minValue, '100000');
708743
});
744+
745+
it('should accept valid txFormat values', function () {
746+
const validBodyLegacy = {
747+
txFormat: 'legacy',
748+
};
749+
const validBodyPsbt = {
750+
txFormat: 'psbt',
751+
};
752+
const validBodyPsbtLite = {
753+
txFormat: 'psbt-lite',
754+
};
755+
756+
const decodedLegacy = assertDecode(t.type(FanoutUnspentsRequestBody), validBodyLegacy);
757+
assert.strictEqual(decodedLegacy.txFormat, 'legacy');
758+
759+
const decodedPsbt = assertDecode(t.type(FanoutUnspentsRequestBody), validBodyPsbt);
760+
assert.strictEqual(decodedPsbt.txFormat, 'psbt');
761+
762+
const decodedPsbtLite = assertDecode(t.type(FanoutUnspentsRequestBody), validBodyPsbtLite);
763+
assert.strictEqual(decodedPsbtLite.txFormat, 'psbt-lite');
764+
});
765+
766+
it('should allow txFormat to be undefined', function () {
767+
const validBody = {
768+
numUnspentsToMake: 10,
769+
};
770+
771+
const decoded = assertDecode(t.type(FanoutUnspentsRequestBody), validBody);
772+
assert.strictEqual(decoded.txFormat, undefined);
773+
});
774+
775+
it('should reject invalid txFormat values', function () {
776+
const invalidBody = {
777+
txFormat: 'invalid-format',
778+
};
779+
780+
assert.throws(() => {
781+
assertDecode(t.type(FanoutUnspentsRequestBody), invalidBody);
782+
});
783+
});
784+
785+
it('should reject non-string txFormat', function () {
786+
const invalidBody = {
787+
txFormat: 123, // number instead of string
788+
};
789+
790+
assert.throws(() => {
791+
assertDecode(t.type(FanoutUnspentsRequestBody), invalidBody);
792+
});
793+
});
709794
});
710795

711796
describe('FanoutUnspentsResponse V2', function () {

0 commit comments

Comments
 (0)