Skip to content

Commit e82bebb

Browse files
committed
fix(express): walletRecoverToken type codec
Ticket: WP-6658
1 parent 4924a3a commit e82bebb

File tree

2 files changed

+99
-37
lines changed

2 files changed

+99
-37
lines changed

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as t from 'io-ts';
22
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
33
import { BitgoExpressError } from '../../schemas/error';
4+
import { Recipient } from './coinSignTx';
45

56
/**
67
* Path parameters for recovering tokens from a wallet
@@ -14,12 +15,14 @@ export const RecoverTokenParams = {
1415

1516
/**
1617
* Request body for recovering tokens from a wallet
18+
*
19+
* Note: When broadcast=false (default), either walletPassphrase or prv must be provided for signing.
1720
*/
1821
export const RecoverTokenBody = {
19-
/** The contract address of the unsupported token to recover */
20-
tokenContractAddress: optional(t.string),
21-
/** The destination address where recovered tokens should be sent */
22-
recipient: optional(t.string),
22+
/** The contract address of the unsupported token to recover (REQUIRED) */
23+
tokenContractAddress: t.string,
24+
/** The destination address where recovered tokens should be sent (REQUIRED) */
25+
recipient: t.string,
2326
/** Whether to automatically broadcast the half-signed transaction to BitGo for cosigning and broadcasting */
2427
broadcast: optional(t.boolean),
2528
/** The wallet passphrase used to decrypt the user key */
@@ -34,8 +37,8 @@ export const RecoverTokenBody = {
3437
export const RecoverTokenResponse = t.type({
3538
halfSigned: t.type({
3639
/** Recipient information for the recovery transaction */
37-
recipient: t.unknown,
38-
/** Expiration time for the transaction */
40+
recipient: Recipient,
41+
/** Expiration time for the transaction (Unix timestamp in seconds) */
3942
expireTime: t.number,
4043
/** Contract sequence ID */
4144
contractSequenceId: t.number,
@@ -61,6 +64,15 @@ export const RecoverTokenResponse = t.type({
6164
* The transaction can be manually submitted to BitGo for cosigning, or automatically broadcast
6265
* by setting the 'broadcast' parameter to true.
6366
*
67+
* Requirements:
68+
* - tokenContractAddress (REQUIRED): The ERC-20 token contract address
69+
* - recipient (REQUIRED): The destination address for recovered tokens
70+
* - walletPassphrase or prv (REQUIRED when broadcast=false): For signing the transaction
71+
*
72+
* Behavior:
73+
* - When broadcast=false (default): Returns a half-signed transaction for manual submission
74+
* - When broadcast=true: Automatically sends the transaction to BitGo for cosigning and broadcasting
75+
*
6476
* Note: This endpoint is only supported for ETH family wallets.
6577
*
6678
* @tag express

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

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -178,33 +178,20 @@ describe('WalletRecoverToken codec tests', function () {
178178
}
179179
});
180180

181-
it('should recover tokens without optional recipient (uses default)', async function () {
181+
it('should reject request without required recipient field', async function () {
182182
const requestBody = {
183183
tokenContractAddress: '0x1234567890123456789012345678901234567890',
184184
walletPassphrase: 'test_passphrase',
185185
};
186186

187-
const mockWallet = {
188-
recoverToken: sinon.stub().resolves(mockRecoverTokenResponse),
189-
};
190-
191-
const walletsGetStub = sinon.stub().resolves(mockWallet);
192-
const mockWallets = { get: walletsGetStub };
193-
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
194-
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
195-
196187
const result = await agent
197188
.post(`/api/v2/${coin}/wallet/${walletId}/recovertoken`)
198189
.set('Authorization', 'Bearer test_access_token_12345')
199190
.set('Content-Type', 'application/json')
200191
.send(requestBody);
201192

202-
assert.strictEqual(result.status, 200);
203-
const decodedResponse = assertDecode(RecoverTokenResponse, result.body);
204-
assert.strictEqual(
205-
decodedResponse.halfSigned.tokenContractAddress,
206-
mockRecoverTokenResponse.halfSigned.tokenContractAddress
207-
);
193+
// Should fail validation because recipient is required
194+
assert.ok(result.status >= 400);
208195
});
209196

210197
// ==========================================
@@ -215,6 +202,7 @@ describe('WalletRecoverToken codec tests', function () {
215202
it('should handle wallet not found error', async function () {
216203
const requestBody = {
217204
tokenContractAddress: '0x1234567890123456789012345678901234567890',
205+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
218206
walletPassphrase: 'test_passphrase',
219207
};
220208

@@ -236,6 +224,7 @@ describe('WalletRecoverToken codec tests', function () {
236224
it('should handle recoverToken failure', async function () {
237225
const requestBody = {
238226
tokenContractAddress: '0x1234567890123456789012345678901234567890',
227+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
239228
walletPassphrase: 'wrong_passphrase',
240229
};
241230

@@ -261,6 +250,7 @@ describe('WalletRecoverToken codec tests', function () {
261250
it('should handle unsupported coin error', async function () {
262251
const requestBody = {
263252
tokenContractAddress: '0x1234567890123456789012345678901234567890',
253+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
264254
walletPassphrase: 'test_passphrase',
265255
};
266256

@@ -279,6 +269,7 @@ describe('WalletRecoverToken codec tests', function () {
279269
it('should handle invalid token contract address error', async function () {
280270
const requestBody = {
281271
tokenContractAddress: 'invalid_address',
272+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
282273
walletPassphrase: 'test_passphrase',
283274
};
284275

@@ -304,6 +295,7 @@ describe('WalletRecoverToken codec tests', function () {
304295
it('should handle no tokens to recover error', async function () {
305296
const requestBody = {
306297
tokenContractAddress: '0x1234567890123456789012345678901234567890',
298+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
307299
walletPassphrase: 'test_passphrase',
308300
};
309301

@@ -329,6 +321,7 @@ describe('WalletRecoverToken codec tests', function () {
329321
it('should handle insufficient funds for gas error', async function () {
330322
const requestBody = {
331323
tokenContractAddress: '0x1234567890123456789012345678901234567890',
324+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
332325
walletPassphrase: 'test_passphrase',
333326
};
334327

@@ -354,6 +347,7 @@ describe('WalletRecoverToken codec tests', function () {
354347
it('should handle coin() method error', async function () {
355348
const requestBody = {
356349
tokenContractAddress: '0x1234567890123456789012345678901234567890',
350+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
357351
walletPassphrase: 'test_passphrase',
358352
};
359353

@@ -374,6 +368,7 @@ describe('WalletRecoverToken codec tests', function () {
374368
it('should reject request with invalid tokenContractAddress type', async function () {
375369
const requestBody = {
376370
tokenContractAddress: 123, // number instead of string
371+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
377372
walletPassphrase: 'test_passphrase',
378373
};
379374

@@ -388,6 +383,7 @@ describe('WalletRecoverToken codec tests', function () {
388383

389384
it('should reject request with invalid recipient type', async function () {
390385
const requestBody = {
386+
tokenContractAddress: '0x1234567890123456789012345678901234567890',
391387
recipient: 123, // number instead of string
392388
walletPassphrase: 'test_passphrase',
393389
};
@@ -404,6 +400,7 @@ describe('WalletRecoverToken codec tests', function () {
404400
it('should reject request with invalid broadcast type', async function () {
405401
const requestBody = {
406402
tokenContractAddress: '0x1234567890123456789012345678901234567890',
403+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
407404
broadcast: 'true', // string instead of boolean
408405
walletPassphrase: 'test_passphrase',
409406
};
@@ -420,6 +417,7 @@ describe('WalletRecoverToken codec tests', function () {
420417
it('should reject request with invalid walletPassphrase type', async function () {
421418
const requestBody = {
422419
tokenContractAddress: '0x1234567890123456789012345678901234567890',
420+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
423421
walletPassphrase: 123, // number instead of string
424422
};
425423

@@ -435,6 +433,7 @@ describe('WalletRecoverToken codec tests', function () {
435433
it('should reject request with invalid prv type', async function () {
436434
const requestBody = {
437435
tokenContractAddress: '0x1234567890123456789012345678901234567890',
436+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
438437
prv: 123, // number instead of string
439438
};
440439

@@ -456,13 +455,46 @@ describe('WalletRecoverToken codec tests', function () {
456455

457456
assert.ok(result.status >= 400);
458457
});
458+
459+
it('should reject request with missing tokenContractAddress', async function () {
460+
const requestBody = {
461+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
462+
walletPassphrase: 'test_passphrase',
463+
};
464+
465+
const result = await agent
466+
.post(`/api/v2/${coin}/wallet/${walletId}/recovertoken`)
467+
.set('Authorization', 'Bearer test_access_token_12345')
468+
.set('Content-Type', 'application/json')
469+
.send(requestBody);
470+
471+
// Should fail validation because tokenContractAddress is required
472+
assert.ok(result.status >= 400);
473+
});
474+
475+
it('should reject request with missing recipient', async function () {
476+
const requestBody = {
477+
tokenContractAddress: '0x1234567890123456789012345678901234567890',
478+
walletPassphrase: 'test_passphrase',
479+
};
480+
481+
const result = await agent
482+
.post(`/api/v2/${coin}/wallet/${walletId}/recovertoken`)
483+
.set('Authorization', 'Bearer test_access_token_12345')
484+
.set('Content-Type', 'application/json')
485+
.send(requestBody);
486+
487+
// Should fail validation because recipient is required
488+
assert.ok(result.status >= 400);
489+
});
459490
});
460491

461492
describe('Edge Cases', function () {
462493
it('should handle very long wallet ID', async function () {
463494
const veryLongWalletId = 'a'.repeat(1000);
464495
const requestBody = {
465496
tokenContractAddress: '0x1234567890123456789012345678901234567890',
497+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
466498
walletPassphrase: 'test_passphrase',
467499
};
468500

@@ -484,6 +516,7 @@ describe('WalletRecoverToken codec tests', function () {
484516
const specialCharWalletId = '../../../etc/passwd';
485517
const requestBody = {
486518
tokenContractAddress: '0x1234567890123456789012345678901234567890',
519+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
487520
walletPassphrase: 'test_passphrase',
488521
};
489522

@@ -504,6 +537,7 @@ describe('WalletRecoverToken codec tests', function () {
504537
it('should handle both walletPassphrase and prv provided', async function () {
505538
const requestBody = {
506539
tokenContractAddress: '0x1234567890123456789012345678901234567890',
540+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
507541
walletPassphrase: 'test_passphrase',
508542
prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
509543
};
@@ -554,6 +588,7 @@ describe('WalletRecoverToken codec tests', function () {
554588
it('should handle invalid Ethereum address format', async function () {
555589
const requestBody = {
556590
tokenContractAddress: '0xinvalid',
591+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
557592
walletPassphrase: 'test_passphrase',
558593
};
559594

@@ -579,6 +614,7 @@ describe('WalletRecoverToken codec tests', function () {
579614
it('should handle checksum address validation', async function () {
580615
const requestBody = {
581616
tokenContractAddress: '0x1234567890ABCDEF1234567890ABCDEF12345678', // Mixed case (checksum)
617+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
582618
walletPassphrase: 'test_passphrase',
583619
};
584620

@@ -606,6 +642,7 @@ describe('WalletRecoverToken codec tests', function () {
606642
it('should reject response with missing required field', async function () {
607643
const requestBody = {
608644
tokenContractAddress: '0x1234567890123456789012345678901234567890',
645+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
609646
walletPassphrase: 'test_passphrase',
610647
};
611648

@@ -644,6 +681,7 @@ describe('WalletRecoverToken codec tests', function () {
644681
it('should reject response with wrong type in field', async function () {
645682
const requestBody = {
646683
tokenContractAddress: '0x1234567890123456789012345678901234567890',
684+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
647685
walletPassphrase: 'test_passphrase',
648686
};
649687

@@ -743,64 +781,76 @@ describe('WalletRecoverToken codec tests', function () {
743781
});
744782

745783
describe('RecoverTokenBody', function () {
746-
it('should validate empty body (all fields optional)', function () {
747-
const validBody = {};
784+
it('should reject empty body (required fields missing)', function () {
785+
const invalidBody = {};
748786

749-
const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
750-
assert.strictEqual(decoded.tokenContractAddress, undefined);
751-
assert.strictEqual(decoded.recipient, undefined);
752-
assert.strictEqual(decoded.broadcast, undefined);
753-
assert.strictEqual(decoded.walletPassphrase, undefined);
754-
assert.strictEqual(decoded.prv, undefined);
787+
// Should fail because tokenContractAddress and recipient are required
788+
assert.throws(() => {
789+
assertDecode(t.type(RecoverTokenBody), invalidBody);
790+
});
755791
});
756792

757-
it('should validate body with tokenContractAddress', function () {
793+
it('should validate body with required fields only', function () {
758794
const validBody = {
759795
tokenContractAddress: '0x1234567890123456789012345678901234567890',
796+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
760797
};
761798

762799
const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
763800
assert.strictEqual(decoded.tokenContractAddress, validBody.tokenContractAddress);
764-
assert.strictEqual(decoded.recipient, undefined);
801+
assert.strictEqual(decoded.recipient, validBody.recipient);
765802
assert.strictEqual(decoded.broadcast, undefined);
766803
assert.strictEqual(decoded.walletPassphrase, undefined);
767804
assert.strictEqual(decoded.prv, undefined);
768805
});
769806

770-
it('should validate body with recipient', function () {
771-
const validBody = {
807+
it('should reject body with only recipient (tokenContractAddress missing)', function () {
808+
const invalidBody = {
772809
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
773810
};
774811

775-
const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
776-
assert.strictEqual(decoded.recipient, validBody.recipient);
777-
assert.strictEqual(decoded.tokenContractAddress, undefined);
812+
// Should fail because tokenContractAddress is required
813+
assert.throws(() => {
814+
assertDecode(t.type(RecoverTokenBody), invalidBody);
815+
});
778816
});
779817

780818
it('should validate body with broadcast', function () {
781819
const validBody = {
820+
tokenContractAddress: '0x1234567890123456789012345678901234567890',
821+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
782822
broadcast: true,
783823
};
784824

785825
const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
826+
assert.strictEqual(decoded.tokenContractAddress, validBody.tokenContractAddress);
827+
assert.strictEqual(decoded.recipient, validBody.recipient);
786828
assert.strictEqual(decoded.broadcast, validBody.broadcast);
787829
});
788830

789831
it('should validate body with walletPassphrase', function () {
790832
const validBody = {
833+
tokenContractAddress: '0x1234567890123456789012345678901234567890',
834+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
791835
walletPassphrase: 'mySecurePassphrase',
792836
};
793837

794838
const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
839+
assert.strictEqual(decoded.tokenContractAddress, validBody.tokenContractAddress);
840+
assert.strictEqual(decoded.recipient, validBody.recipient);
795841
assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase);
796842
});
797843

798844
it('should validate body with prv', function () {
799845
const validBody = {
846+
tokenContractAddress: '0x1234567890123456789012345678901234567890',
847+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
800848
prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
801849
};
802850

803851
const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
852+
assert.strictEqual(decoded.tokenContractAddress, validBody.tokenContractAddress);
853+
assert.strictEqual(decoded.recipient, validBody.recipient);
804854
assert.strictEqual(decoded.prv, validBody.prv);
805855
});
806856

0 commit comments

Comments
 (0)