Skip to content

Commit 753739a

Browse files
authored
fix(express): construct pending approval type codec
2 parents 2e3dd52 + 3e5d4e1 commit 753739a

File tree

2 files changed

+112
-26
lines changed

2 files changed

+112
-26
lines changed

modules/express/src/typedRoutes/api/v1/constructPendingApprovalTx.ts

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,50 +14,79 @@ export const ConstructPendingApprovalTxRequestParams = {
1414
* Request body for constructing a pending approval transaction
1515
*/
1616
export const ConstructPendingApprovalTxRequestBody = {
17-
/** The wallet passphrase to decrypt the user key (either walletPassphrase or xprv must be provided for transactionRequest type) */
17+
/**
18+
* The wallet passphrase to decrypt the user key. Required for transactionRequest type approvals
19+
* if xprv is not provided. Use this to sign the transaction with the wallet's encrypted key.
20+
*/
1821
walletPassphrase: optional(t.string),
19-
/** The extended private key (alternative to walletPassphrase) */
22+
/**
23+
* The extended private key in BIP32 format (xprv...). Alternative to walletPassphrase.
24+
* Required for transactionRequest type approvals if walletPassphrase is not provided.
25+
*/
2026
xprv: optional(t.string),
21-
/** Whether to use the original fee from the transaction request (cannot be used with fee, feeRate, or feeTxConfirmTarget) */
27+
/**
28+
* Whether to use the original fee from the transaction request. Applies to transactionRequest
29+
* type approvals only. Cannot be used with fee, feeRate, or feeTxConfirmTarget.
30+
*/
2231
useOriginalFee: optional(t.boolean),
23-
/** Custom fee amount in satoshis (cannot be used with useOriginalFee) */
32+
/**
33+
* Custom fee amount in satoshis to use for the transaction. Cannot be used with useOriginalFee.
34+
*/
2435
fee: optional(t.number),
25-
/** Custom fee rate in satoshis per kilobyte (cannot be used with useOriginalFee) */
36+
/**
37+
* Custom fee rate in satoshis per kilobyte. Cannot be used with useOriginalFee.
38+
*/
2639
feeRate: optional(t.number),
27-
/** Custom fee confirmation target in blocks (cannot be used with useOriginalFee) */
40+
/**
41+
* Target number of blocks for fee estimation. The fee will be calculated to confirm the
42+
* transaction within this many blocks. Cannot be used with useOriginalFee.
43+
*/
2844
feeTxConfirmTarget: optional(t.number),
2945
};
3046

3147
/**
3248
* Response for constructing a pending approval transaction
3349
*/
3450
export const ConstructPendingApprovalTxResponse = t.type({
35-
/** The signed transaction hex */
51+
/** The signed transaction in hex-encoded format, ready for broadcast to the network */
3652
tx: t.string,
37-
/** The fee amount in satoshis */
53+
/** The total fee amount in satoshis paid for this transaction (optional) */
3854
fee: optional(t.number),
39-
/** The fee rate in satoshis per kilobyte */
55+
/** The fee rate in satoshis per kilobyte used for this transaction (optional) */
4056
feeRate: optional(t.number),
41-
/** Whether the transaction is instant */
57+
/** Whether this is an instant (RBF-disabled) transaction that cannot be fee-bumped (optional) */
4258
instant: optional(t.boolean),
43-
/** The BitGo fee amount */
59+
/** BitGo service fee information including amount and address (optional) */
4460
bitgoFee: optional(t.unknown),
45-
/** Travel information */
61+
/** Travel Rule compliance information for regulated transactions (optional) */
4662
travelInfos: optional(t.unknown),
47-
/** Estimated transaction size in bytes */
63+
/** Estimated size of the transaction in bytes (optional) */
4864
estimatedSize: optional(t.number),
49-
/** Unspent transaction outputs used */
65+
/** Array of unspent transaction outputs (UTXOs) used as inputs in this transaction (optional) */
5066
unspents: optional(t.array(t.unknown)),
5167
});
5268

5369
/**
5470
* Construct a pending approval transaction
5571
*
56-
* This endpoint constructs and signs a transaction for a pending approval, returning the transaction hex
57-
* but not sending it to the network. This is useful for reviewing the transaction before approving it.
72+
* Constructs and signs a transaction for a pending approval without broadcasting it to the network.
73+
* This endpoint allows you to preview and validate the transaction details before final approval.
5874
*
59-
* For transaction request type approvals, either a wallet passphrase or xprv must be provided to sign the transaction.
60-
* You can optionally specify fee-related parameters to customize the transaction fee.
75+
* **Authentication Requirements:**
76+
* - For transactionRequest type approvals, you must provide either walletPassphrase or xprv to sign the transaction
77+
* - The user must have permission to approve the pending approval
78+
*
79+
* **Fee Customization:**
80+
* - Use `useOriginalFee: true` to preserve the fee from the original transaction request
81+
* - Alternatively, specify custom fee parameters (fee, feeRate, or feeTxConfirmTarget)
82+
* - Fee parameters cannot be combined with useOriginalFee
83+
*
84+
* **Workflow:**
85+
* 1. Retrieves the pending approval by ID
86+
* 2. Constructs the transaction according to the approval parameters
87+
* 3. Signs the transaction (for transactionRequest approvals)
88+
* 4. Returns the signed transaction hex and metadata
89+
* 5. Transaction is NOT broadcast to the network
6190
*
6291
* @operationId express.v1.pendingapproval.constructTx
6392
* @tag express
@@ -70,9 +99,16 @@ export const PutConstructPendingApprovalTx = httpRoute({
7099
body: ConstructPendingApprovalTxRequestBody,
71100
}),
72101
response: {
73-
/** Successfully constructed transaction */
102+
/** Successfully constructed and signed the transaction. Returns transaction hex and metadata. */
74103
200: ConstructPendingApprovalTxResponse,
75-
/** Invalid request or construction fails */
104+
/**
105+
* Bad request. Possible reasons:
106+
* - Invalid pending approval ID
107+
* - Missing required authentication (walletPassphrase or xprv for transactionRequest)
108+
* - Invalid fee parameters (e.g., combining useOriginalFee with other fee options)
109+
* - Incorrect wallet passphrase or xprv
110+
* - Transaction construction failed
111+
*/
76112
400: BitgoExpressError,
77113
},
78114
});

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

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,13 +192,20 @@ describe('ConstructPendingApprovalTx codec tests', function () {
192192
});
193193

194194
describe('ConstructPendingApprovalTxResponse', function () {
195-
it('should validate response with required tx field', function () {
195+
it('should validate response with only required tx field', function () {
196196
const validResponse = {
197197
tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000',
198198
};
199199

200200
const decoded = assertDecode(ConstructPendingApprovalTxResponse, validResponse);
201201
assert.strictEqual(decoded.tx, validResponse.tx);
202+
assert.strictEqual(decoded.fee, undefined);
203+
assert.strictEqual(decoded.feeRate, undefined);
204+
assert.strictEqual(decoded.instant, undefined);
205+
assert.strictEqual(decoded.bitgoFee, undefined);
206+
assert.strictEqual(decoded.travelInfos, undefined);
207+
assert.strictEqual(decoded.estimatedSize, undefined);
208+
assert.strictEqual(decoded.unspents, undefined);
202209
});
203210

204211
it('should validate response with all fields', function () {
@@ -244,7 +251,7 @@ describe('ConstructPendingApprovalTx codec tests', function () {
244251
});
245252
});
246253

247-
it('should reject response with non-number fee', function () {
254+
it('should reject response with non-number fee (optional field, but must be number if provided)', function () {
248255
const invalidResponse = {
249256
tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000',
250257
fee: '10000', // string instead of number
@@ -255,7 +262,7 @@ describe('ConstructPendingApprovalTx codec tests', function () {
255262
});
256263
});
257264

258-
it('should reject response with non-number feeRate', function () {
265+
it('should reject response with non-number feeRate (optional field, but must be number if provided)', function () {
259266
const invalidResponse = {
260267
tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000',
261268
feeRate: '20000', // string instead of number
@@ -266,7 +273,7 @@ describe('ConstructPendingApprovalTx codec tests', function () {
266273
});
267274
});
268275

269-
it('should reject response with non-boolean instant', function () {
276+
it('should reject response with non-boolean instant (optional field, but must be boolean if provided)', function () {
270277
const invalidResponse = {
271278
tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000',
272279
instant: 'false', // string instead of boolean
@@ -276,7 +283,8 @@ describe('ConstructPendingApprovalTx codec tests', function () {
276283
assertDecode(ConstructPendingApprovalTxResponse, invalidResponse);
277284
});
278285
});
279-
it('should reject response with non-number estimatedSize', function () {
286+
287+
it('should reject response with non-number estimatedSize (optional field, but must be number if provided)', function () {
280288
const invalidResponse = {
281289
tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000',
282290
estimatedSize: '256', // string instead of number
@@ -386,6 +394,8 @@ describe('ConstructPendingApprovalTx codec tests', function () {
386394
fee: 10000,
387395
feeRate: 20000,
388396
instant: false,
397+
estimatedSize: 256,
398+
unspents: [{ id: 'unspent1', value: 1000000 }],
389399
};
390400

391401
afterEach(function () {
@@ -523,6 +533,46 @@ describe('ConstructPendingApprovalTx codec tests', function () {
523533
assert.strictEqual(decodedResponse.fee, 15000);
524534
});
525535

536+
it('should successfully construct a pending approval transaction with minimal response (only tx)', async function () {
537+
const requestBody = {
538+
walletPassphrase: 'mySecurePassphrase',
539+
};
540+
541+
const minimalMockResponse = {
542+
tx: '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000',
543+
};
544+
545+
const mockPendingApproval = {
546+
constructApprovalTx: sinon.stub().resolves(minimalMockResponse),
547+
};
548+
549+
const mockPendingApprovals = {
550+
get: sinon.stub().resolves(mockPendingApproval),
551+
};
552+
553+
sinon.stub(BitGo.prototype, 'pendingApprovals').returns(mockPendingApprovals as any);
554+
555+
const result = await agent
556+
.put('/api/v1/pendingapprovals/test-approval-id-123/constructTx')
557+
.set('Authorization', 'Bearer test_access_token_12345')
558+
.set('Content-Type', 'application/json')
559+
.send(requestBody);
560+
561+
assert.strictEqual(result.status, 200);
562+
result.body.should.have.property('tx');
563+
assert.strictEqual(result.body.tx, minimalMockResponse.tx);
564+
565+
const decodedResponse = assertDecode(ConstructPendingApprovalTxResponse, result.body);
566+
assert.strictEqual(decodedResponse.tx, minimalMockResponse.tx);
567+
assert.strictEqual(decodedResponse.fee, undefined);
568+
assert.strictEqual(decodedResponse.feeRate, undefined);
569+
assert.strictEqual(decodedResponse.instant, undefined);
570+
assert.strictEqual(decodedResponse.bitgoFee, undefined);
571+
assert.strictEqual(decodedResponse.travelInfos, undefined);
572+
assert.strictEqual(decodedResponse.estimatedSize, undefined);
573+
assert.strictEqual(decodedResponse.unspents, undefined);
574+
});
575+
526576
it('should successfully construct a pending approval transaction with all optional response fields', async function () {
527577
const requestBody = {
528578
walletPassphrase: 'mySecurePassphrase',
@@ -866,7 +916,7 @@ describe('ConstructPendingApprovalTx codec tests', function () {
866916
});
867917
});
868918

869-
it('should reject response with wrong type for fee', async function () {
919+
it('should reject response with wrong type for fee (optional but must be number if provided)', async function () {
870920
const requestBody = {
871921
walletPassphrase: 'mySecurePassphrase',
872922
};

0 commit comments

Comments
 (0)