Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions modules/express/src/typedRoutes/api/v2/walletRecoverToken.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as t from 'io-ts';
import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http';
import { BitgoExpressError } from '../../schemas/error';
import { Recipient } from './coinSignTx';

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

/**
* Request body for recovering tokens from a wallet
*
* Note: When broadcast=false (default), either walletPassphrase or prv must be provided for signing.
*/
export const RecoverTokenBody = {
/** The contract address of the unsupported token to recover */
tokenContractAddress: optional(t.string),
/** The destination address where recovered tokens should be sent */
recipient: optional(t.string),
/** The contract address of the unsupported token to recover (REQUIRED) */
tokenContractAddress: t.string,
/** The destination address where recovered tokens should be sent (REQUIRED) */
recipient: t.string,
/** Whether to automatically broadcast the half-signed transaction to BitGo for cosigning and broadcasting */
broadcast: optional(t.boolean),
/** The wallet passphrase used to decrypt the user key */
Expand All @@ -34,8 +37,8 @@ export const RecoverTokenBody = {
export const RecoverTokenResponse = t.type({
halfSigned: t.type({
/** Recipient information for the recovery transaction */
recipient: t.unknown,
/** Expiration time for the transaction */
recipient: Recipient,
/** Expiration time for the transaction (Unix timestamp in seconds) */
expireTime: t.number,
/** Contract sequence ID */
contractSequenceId: t.number,
Expand All @@ -61,6 +64,15 @@ export const RecoverTokenResponse = t.type({
* The transaction can be manually submitted to BitGo for cosigning, or automatically broadcast
* by setting the 'broadcast' parameter to true.
*
* Requirements:
* - tokenContractAddress (REQUIRED): The ERC-20 token contract address
* - recipient (REQUIRED): The destination address for recovered tokens
* - walletPassphrase or prv (REQUIRED when broadcast=false): For signing the transaction
*
* Behavior:
* - When broadcast=false (default): Returns a half-signed transaction for manual submission
* - When broadcast=true: Automatically sends the transaction to BitGo for cosigning and broadcasting
*
* Note: This endpoint is only supported for ETH family wallets.
*
* @tag express
Expand Down
112 changes: 81 additions & 31 deletions modules/express/test/unit/typedRoutes/walletRecoverToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,33 +178,20 @@ describe('WalletRecoverToken codec tests', function () {
}
});

it('should recover tokens without optional recipient (uses default)', async function () {
it('should reject request without required recipient field', async function () {
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
walletPassphrase: 'test_passphrase',
};

const mockWallet = {
recoverToken: sinon.stub().resolves(mockRecoverTokenResponse),
};

const walletsGetStub = sinon.stub().resolves(mockWallet);
const mockWallets = { get: walletsGetStub };
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);

const result = await agent
.post(`/api/v2/${coin}/wallet/${walletId}/recovertoken`)
.set('Authorization', 'Bearer test_access_token_12345')
.set('Content-Type', 'application/json')
.send(requestBody);

assert.strictEqual(result.status, 200);
const decodedResponse = assertDecode(RecoverTokenResponse, result.body);
assert.strictEqual(
decodedResponse.halfSigned.tokenContractAddress,
mockRecoverTokenResponse.halfSigned.tokenContractAddress
);
// Should fail validation because recipient is required
assert.ok(result.status >= 400);
});

// ==========================================
Expand All @@ -215,6 +202,7 @@ describe('WalletRecoverToken codec tests', function () {
it('should handle wallet not found error', async function () {
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 'test_passphrase',
};

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

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

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

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

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

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

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

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

it('should reject request with invalid recipient type', async function () {
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: 123, // number instead of string
walletPassphrase: 'test_passphrase',
};
Expand All @@ -404,6 +400,7 @@ describe('WalletRecoverToken codec tests', function () {
it('should reject request with invalid broadcast type', async function () {
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
broadcast: 'true', // string instead of boolean
walletPassphrase: 'test_passphrase',
};
Expand All @@ -420,6 +417,7 @@ describe('WalletRecoverToken codec tests', function () {
it('should reject request with invalid walletPassphrase type', async function () {
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 123, // number instead of string
};

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

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

assert.ok(result.status >= 400);
});

it('should reject request with missing tokenContractAddress', async function () {
const requestBody = {
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 'test_passphrase',
};

const result = await agent
.post(`/api/v2/${coin}/wallet/${walletId}/recovertoken`)
.set('Authorization', 'Bearer test_access_token_12345')
.set('Content-Type', 'application/json')
.send(requestBody);

// Should fail validation because tokenContractAddress is required
assert.ok(result.status >= 400);
});

it('should reject request with missing recipient', async function () {
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
walletPassphrase: 'test_passphrase',
};

const result = await agent
.post(`/api/v2/${coin}/wallet/${walletId}/recovertoken`)
.set('Authorization', 'Bearer test_access_token_12345')
.set('Content-Type', 'application/json')
.send(requestBody);

// Should fail validation because recipient is required
assert.ok(result.status >= 400);
});
});

describe('Edge Cases', function () {
it('should handle very long wallet ID', async function () {
const veryLongWalletId = 'a'.repeat(1000);
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 'test_passphrase',
};

Expand All @@ -484,6 +516,7 @@ describe('WalletRecoverToken codec tests', function () {
const specialCharWalletId = '../../../etc/passwd';
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 'test_passphrase',
};

Expand All @@ -504,6 +537,7 @@ describe('WalletRecoverToken codec tests', function () {
it('should handle both walletPassphrase and prv provided', async function () {
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 'test_passphrase',
prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
};
Expand Down Expand Up @@ -554,6 +588,7 @@ describe('WalletRecoverToken codec tests', function () {
it('should handle invalid Ethereum address format', async function () {
const requestBody = {
tokenContractAddress: '0xinvalid',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 'test_passphrase',
};

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

Expand Down Expand Up @@ -606,6 +642,7 @@ describe('WalletRecoverToken codec tests', function () {
it('should reject response with missing required field', async function () {
const requestBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 'test_passphrase',
};

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

Expand Down Expand Up @@ -743,64 +781,76 @@ describe('WalletRecoverToken codec tests', function () {
});

describe('RecoverTokenBody', function () {
it('should validate empty body (all fields optional)', function () {
const validBody = {};
it('should reject empty body (required fields missing)', function () {
const invalidBody = {};

const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
assert.strictEqual(decoded.tokenContractAddress, undefined);
assert.strictEqual(decoded.recipient, undefined);
assert.strictEqual(decoded.broadcast, undefined);
assert.strictEqual(decoded.walletPassphrase, undefined);
assert.strictEqual(decoded.prv, undefined);
// Should fail because tokenContractAddress and recipient are required
assert.throws(() => {
assertDecode(t.type(RecoverTokenBody), invalidBody);
});
});

it('should validate body with tokenContractAddress', function () {
it('should validate body with required fields only', function () {
const validBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
};

const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
assert.strictEqual(decoded.tokenContractAddress, validBody.tokenContractAddress);
assert.strictEqual(decoded.recipient, undefined);
assert.strictEqual(decoded.recipient, validBody.recipient);
assert.strictEqual(decoded.broadcast, undefined);
assert.strictEqual(decoded.walletPassphrase, undefined);
assert.strictEqual(decoded.prv, undefined);
});

it('should validate body with recipient', function () {
const validBody = {
it('should reject body with only recipient (tokenContractAddress missing)', function () {
const invalidBody = {
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
};

const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
assert.strictEqual(decoded.recipient, validBody.recipient);
assert.strictEqual(decoded.tokenContractAddress, undefined);
// Should fail because tokenContractAddress is required
assert.throws(() => {
assertDecode(t.type(RecoverTokenBody), invalidBody);
});
});

it('should validate body with broadcast', function () {
const validBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
broadcast: true,
};

const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
assert.strictEqual(decoded.tokenContractAddress, validBody.tokenContractAddress);
assert.strictEqual(decoded.recipient, validBody.recipient);
assert.strictEqual(decoded.broadcast, validBody.broadcast);
});

it('should validate body with walletPassphrase', function () {
const validBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
walletPassphrase: 'mySecurePassphrase',
};

const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
assert.strictEqual(decoded.tokenContractAddress, validBody.tokenContractAddress);
assert.strictEqual(decoded.recipient, validBody.recipient);
assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase);
});

it('should validate body with prv', function () {
const validBody = {
tokenContractAddress: '0x1234567890123456789012345678901234567890',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2',
};

const decoded = assertDecode(t.type(RecoverTokenBody), validBody);
assert.strictEqual(decoded.tokenContractAddress, validBody.tokenContractAddress);
assert.strictEqual(decoded.recipient, validBody.recipient);
assert.strictEqual(decoded.prv, validBody.prv);
});

Expand Down