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
94 changes: 42 additions & 52 deletions packages/wallet-service/src/api/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@ import 'source-map-support/register';
import { walletIdProxyHandler } from '@src/commons';
import {
getWalletTokens,
getTotalSupply,
getTotalTransactions,
getTokenInformation,
getAuthorityUtxo,
} from '@src/db';
import {
TokenInfo,
} from '@src/types';
import { getDbConnection } from '@src/utils';
closeDbConnection,
getDbConnection,
} from '@src/utils';
import { ApiError } from '@src/api/errors';
import { closeDbAndGetError, warmupMiddleware, txIdJoiValidator } from '@src/api/utils';
import fullnode from '@src/fullnode';
import Joi from 'joi';
import { bigIntUtils, constants } from '@hathor/wallet-lib';
import middy from '@middy/core';
import cors from '@middy/http-cors';
import errorHandler from '@src/api/middlewares/errorHandler';
Expand Down Expand Up @@ -49,8 +45,9 @@ const getTokenDetailsParamsSchema = Joi.object({
* Get token details
*
* This lambda is called by API Gateway on GET /wallet/tokens/:token_id/details
* It proxies the request to the fullnode's thin_wallet/token API
*/
export const getTokenDetails = middy(walletIdProxyHandler(async (walletId, event) => {
export const getTokenDetails = middy(walletIdProxyHandler(async (_walletId, event) => {
const params = event.pathParameters || {};

const { value, error } = getTokenDetailsParamsSchema.validate(params, {
Expand All @@ -68,51 +65,44 @@ export const getTokenDetails = middy(walletIdProxyHandler(async (walletId, event
}

const tokenId = value.token_id;
const tokenInfo: TokenInfo = await getTokenInformation(mysql, tokenId);

if (tokenId === constants.NATIVE_TOKEN_UID) {
const details = [{
message: 'Invalid tokenId',
}];

return closeDbAndGetError(mysql, ApiError.INVALID_PAYLOAD, { details });
}

if (!tokenInfo) {
const details = [{
message: 'Token not found',
}];

return closeDbAndGetError(mysql, ApiError.TOKEN_NOT_FOUND, { details });
}

const [
totalSupply,
totalTransactions,
meltAuthority,
mintAuthority,
] = await Promise.all([
getTotalSupply(mysql, tokenId),
getTotalTransactions(mysql, tokenId),
getAuthorityUtxo(mysql, tokenId, Number(constants.TOKEN_MELT_MASK)),
getAuthorityUtxo(mysql, tokenId, Number(constants.TOKEN_MINT_MASK)),
]);

return {
statusCode: 200,
body: bigIntUtils.JSONBigInt.stringify({
success: true,
details: {
tokenInfo,
totalSupply,
totalTransactions,
authorities: {
mint: mintAuthority !== null,
melt: meltAuthority !== null,
try {
const data = await fullnode.getTokenDetails(tokenId);

if (!data?.success) {
return {
statusCode: 404,
body: JSON.stringify({
success: false,
error: ApiError.TOKEN_NOT_FOUND,
details: [{ message: 'Token not found' }],
}),
};
}

return {
statusCode: 200,
body: JSON.stringify({
success: true,
details: {
tokenInfo: {
id: tokenId,
name: data.name,
symbol: data.symbol,
version: data.version,
},
totalSupply: data.total,
totalTransactions: data.transactions_count,
authorities: {
mint: data.can_mint,
melt: data.can_melt,
},
},
},
}),
};
}),
};
} finally {
await closeDbConnection(mysql);
}
Comment on lines +103 to +105
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat packages/wallet-service/src/api/tokens.ts | head -120

Repository: HathorNetwork/hathor-wallet-service

Length of output: 2764


🏁 Script executed:

rg -n "closeDbConnection|closeDb" packages/wallet-service/src/api/ -C2

Repository: HathorNetwork/hathor-wallet-service

Length of output: 50391


🏁 Script executed:

rg -n "getDbConnection" packages/wallet-service/src/ -A10

Repository: HathorNetwork/hathor-wallet-service

Length of output: 47728


🏁 Script executed:

fd "connection|db" packages/wallet-service/src/ -t f | grep -E "\.(ts|js)$"

Repository: HathorNetwork/hathor-wallet-service

Length of output: 123


🏁 Script executed:

# Let me check if there are any tests or documentation about how the connection pool handles closing
rg -i "serverless.*mysql" packages/wallet-service/src/ -A2 | head -40

Repository: HathorNetwork/hathor-wallet-service

Length of output: 2861


🏁 Script executed:

# Check if closeDbConnection is called in error handlers
rg "closeDbConnection|closeDbAndGetError" packages/wallet-service/src/api/utils.ts -A3 -B3

Repository: HathorNetwork/hathor-wallet-service

Length of output: 661


Add connection closing to the get function for consistency and resource management.

The get function (lines 26-38) should close the database connection like getTokenDetails does. Currently, get performs a database query but never calls closeDbConnection(mysql), while getTokenDetails properly closes the connection in a finally block. This inconsistency can lead to connection pool exhaustion and resource leaks on warm Lambda instances where the module-level connection persists across invocations. Add a finally block after the return statement to ensure the connection is properly released.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wallet-service/src/api/tokens.ts` around lines 103 - 105, The get
function currently opens a DB connection and returns results but never closes
it; mirror the pattern used in getTokenDetails by adding a try/finally (or a
finally block after the existing return) that calls closeDbConnection(mysql) to
always release the connection; locate the get function in tokens.ts and ensure
closeDbConnection(mysql) is invoked in a finally block (same as in
getTokenDetails) so the mysql connection is closed on all execution paths.

})).use(cors())
.use(warmupMiddleware())
.use(errorHandler());
11 changes: 11 additions & 0 deletions packages/wallet-service/src/fullnode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ export const create = (baseURL = BASE_URL) => {
return response.data;
}

const getTokenDetails = async (tokenId: string) => {
const response = await api.get('thin_wallet/token', {
data: null,
params: { id: tokenId },
headers: { 'content-type': 'application/json' },
});

return response.data;
}

return {
api, // exported so we can mock it on the tests
version,
Expand All @@ -129,6 +139,7 @@ export const create = (baseURL = BASE_URL) => {
getNCState,
getNCHistory,
getNCBlueprintInfo,
getTokenDetails,
};
};

Expand Down
195 changes: 55 additions & 140 deletions packages/wallet-service/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,25 @@ test('GET /wallet/tokens/token_id/details', async () => {
// check CORS headers
await _testCORSHeaders(getTokenDetails, null, null);

const mockFullnodeData = {
name: 'MyToken1',
symbol: 'MT1',
version: 1,
success: true,
mint: [
{ tx_id: 'txId', index: 2 },
],
melt: [],
can_mint: true,
can_melt: false,
total: 100,
transactions_count: 1,
};

const spy = jest.spyOn(fullnode, 'getTokenDetails');
const mockFullnodeResponse = jest.fn(() => Promise.resolve(mockFullnodeData));
spy.mockImplementation(mockFullnodeResponse);

await addToWalletTable(mysql, [{
id: 'my-wallet',
xpubkey: 'xpubkey',
Expand All @@ -1482,158 +1501,68 @@ test('GET /wallet/tokens/token_id/details', async () => {
readyAt: 10001,
}]);

let event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: TX_IDS[0] });
// Missing token_id should return validation error
let event = makeGatewayEventWithAuthorizer('my-wallet', null);
let result = await getTokenDetails(event, null, null) as APIGatewayProxyResult;
let returnBody = JSON.parse(result.body as string);

expect(result.statusCode).toBe(404);
expect(returnBody.success).toBe(false);
expect(returnBody.details[0]).toStrictEqual({ message: 'Token not found' });

event = makeGatewayEventWithAuthorizer('my-wallet', null);
result = await getTokenDetails(event, null, null) as APIGatewayProxyResult;
returnBody = JSON.parse(result.body as string);

expect(result.statusCode).toBe(400);
expect(returnBody.success).toBe(false);
expect(returnBody.details[0]).toStrictEqual({ message: '"token_id" is required', path: ['token_id'] });

// add tokens
const token1 = { id: TX_IDS[1], name: 'MyToken1', symbol: 'MT1', version: TokenVersion.DEPOSIT };
const token2 = { id: TX_IDS[2], name: 'MyToken2', symbol: 'MT2', version: TokenVersion.DEPOSIT };

await addToTokenTable(mysql, [
{ id: token1.id, name: token1.name, symbol: token1.symbol, version: TokenVersion.DEPOSIT, transactions: 0 },
{ id: token2.id, name: token2.name, symbol: token2.symbol, version: TokenVersion.DEPOSIT, transactions: 0 },
]);

await addToUtxoTable(mysql, [{
// Total tokens created
txId: 'txId',
index: 0,
tokenId: token1.id,
address: ADDRESSES[0],
value: 100n,
authorities: 0,
timelock: null,
heightlock: null,
locked: false,
spentBy: null,
}, {
// Mint UTXO:
txId: 'txId',
index: 1,
tokenId: token1.id,
address: ADDRESSES[0],
value: 0n,
authorities: Number(constants.TOKEN_MINT_MASK),
timelock: null,
heightlock: null,
locked: false,
spentBy: null,
}, {
// Another Mint UTXO
txId: 'txId',
index: 2,
tokenId: token1.id,
address: ADDRESSES[0],
value: 0n,
authorities: Number(constants.TOKEN_MINT_MASK),
timelock: null,
heightlock: null,
locked: false,
spentBy: null,
}, {
// Total tokens created
txId: 'txId2',
index: 0,
tokenId: token2.id,
address: ADDRESSES[0],
value: 250n,
authorities: 0,
timelock: null,
heightlock: null,
locked: true,
spentBy: null,
}, {
// Locked utxo
txId: 'txId2',
index: 1,
tokenId: token2.id,
address: ADDRESSES[0],
value: 0n,
authorities: Number(constants.TOKEN_MINT_MASK),
timelock: 1000,
heightlock: null,
locked: true,
spentBy: null,
}, {
// Spent utxo
txId: 'txId2',
index: 2,
tokenId: token2.id,
address: ADDRESSES[0],
value: 0n,
authorities: Number(constants.TOKEN_MINT_MASK),
timelock: 1000,
heightlock: null,
locked: true,
spentBy: 'txid2',
}, {
txId: 'txId3',
index: 0,
tokenId: token2.id,
address: ADDRESSES[0],
value: 0n,
authorities: Number(constants.TOKEN_MINT_MASK),
timelock: null,
heightlock: null,
locked: false,
spentBy: null,
}, {
// Melt UTXO
txId: 'txId3',
index: 1,
tokenId: token2.id,
address: ADDRESSES[0],
value: 0n,
authorities: Number(constants.TOKEN_MELT_MASK),
timelock: null,
heightlock: null,
locked: false,
spentBy: null,
}]);

await addToAddressTxHistoryTable(mysql, [
{ address: ADDRESSES[0], txId: 'txId', tokenId: token1.id, balance: 100n, timestamp: 0 },
{ address: ADDRESSES[0], txId: 'txId2', tokenId: token2.id, balance: 250n, timestamp: 0 },
{ address: ADDRESSES[0], txId: 'txId3', tokenId: token2.id, balance: 0n, timestamp: 0 },
]);

event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: token1.id });
// Valid token_id should proxy to fullnode and return in the old response format
event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: TX_IDS[0] });
result = await getTokenDetails(event, null, null) as APIGatewayProxyResult;
returnBody = JSON.parse(result.body as string);

expect(result.statusCode).toBe(200);
expect(returnBody.success).toBe(true);
expect(returnBody.details.tokenInfo).toStrictEqual({
id: TX_IDS[0],
name: 'MyToken1',
symbol: 'MT1',
version: 1,
});
expect(returnBody.details.totalSupply).toStrictEqual(100);
expect(returnBody.details.totalTransactions).toStrictEqual(1);
expect(returnBody.details.authorities.mint).toStrictEqual(true);
expect(returnBody.details.authorities.melt).toStrictEqual(false);
expect(returnBody.details.tokenInfo).toStrictEqual(token1);
expect(mockFullnodeResponse).toHaveBeenNthCalledWith(1, TX_IDS[0]);

// Test with a token that has both mint and melt authorities
const mockFullnodeData2 = {
name: 'MyToken2',
symbol: 'MT2',
version: 1,
success: true,
mint: [{ tx_id: 'txId2', index: 1 }],
melt: [{ tx_id: 'txId2', index: 2 }],
can_mint: true,
can_melt: true,
total: 250,
transactions_count: 2,
};
mockFullnodeResponse.mockResolvedValue(mockFullnodeData2);

event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: token2.id });
event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: TX_IDS[1] });
result = await getTokenDetails(event, null, null) as APIGatewayProxyResult;
returnBody = JSON.parse(result.body as string);

expect(result.statusCode).toBe(200);
expect(returnBody.success).toBe(true);
expect(returnBody.details.tokenInfo).toStrictEqual({
id: TX_IDS[1],
name: 'MyToken2',
symbol: 'MT2',
version: 1,
});
expect(returnBody.details.totalSupply).toStrictEqual(250);
expect(returnBody.details.totalTransactions).toStrictEqual(2);
expect(returnBody.details.authorities.mint).toStrictEqual(true);
expect(returnBody.details.authorities.melt).toStrictEqual(true);
expect(returnBody.details.tokenInfo).toStrictEqual(token2);
expect(mockFullnodeResponse).toHaveBeenNthCalledWith(2, TX_IDS[1]);

// Short token_id should fail validation
event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: constants.NATIVE_TOKEN_UID });
result = await getTokenDetails(event, null, null) as APIGatewayProxyResult;
returnBody = JSON.parse(result.body as string);
Expand All @@ -1651,21 +1580,7 @@ test('GET /wallet/tokens/token_id/details', async () => {
]
`);

const oldHathorTokenConfig = constants.NATIVE_TOKEN_UID;

// @ts-ignore
constants.NATIVE_TOKEN_UID = TX_IDS[4];

event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: constants.NATIVE_TOKEN_UID });
result = await getTokenDetails(event, null, null) as APIGatewayProxyResult;
returnBody = JSON.parse(result.body as string);

expect(result.statusCode).toBe(400);
expect(returnBody.success).toBe(false);
expect(returnBody.details).toStrictEqual([{ message: 'Invalid tokenId' }]);

// @ts-ignore
constants.NATIVE_TOKEN_UID = oldHathorTokenConfig;
spy.mockRestore();
});

test('GET /wallet/utxos', async () => {
Expand Down