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
11 changes: 11 additions & 0 deletions packages/wallet-service/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,17 @@ functions:
warmup:
walletWarmer:
enabled: true
hasTxOutsideFirstAddr:
handler: src/api/hasTxOutsideFirstAddr.get
events:
- http:
path: wallet/addresses/has-transactions-outside-first-address
method: get
cors: true
authorizer: ${self:custom.authorizer.walletBearer}
warmup:
walletWarmer:
enabled: false
getUtxos:
handler: src/api/txOutputs.getFilteredUtxos
events:
Expand Down
55 changes: 55 additions & 0 deletions packages/wallet-service/src/api/hasTxOutsideFirstAddr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Copyright (c) Hathor Labs and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import 'source-map-support/register';

import { ApiError } from '@src/api/errors';
import { closeDbAndGetError, warmupMiddleware } from '@src/api/utils';
import {
getWallet,
hasTransactionsOnNonFirstAddress,
} from '@src/db';
import {
closeDbConnection,
getDbConnection,
} from '@src/utils';
import { walletIdProxyHandler } from '@src/commons';
import middy from '@middy/core';
import cors from '@middy/http-cors';
import errorHandler from '@src/api/middlewares/errorHandler';

const mysql = getDbConnection();

/*
* Check if the wallet has any transactions on addresses with index > 0
*
* This lambda is called by API Gateway on GET /wallet/addresses/has-transactions-outside-first-address
*/
export const get = middy(walletIdProxyHandler(async (walletId) => {
const status = await getWallet(mysql, walletId);

if (!status) {
return closeDbAndGetError(mysql, ApiError.WALLET_NOT_FOUND);
}
if (!status.readyAt) {
return closeDbAndGetError(mysql, ApiError.WALLET_NOT_READY);
}

const hasTransactions = await hasTransactionsOnNonFirstAddress(mysql, walletId);

await closeDbConnection(mysql);

return {
statusCode: 200,
body: JSON.stringify({
success: true,
hasTransactions,
}),
};
})).use(cors())
.use(warmupMiddleware())
.use(errorHandler());
25 changes: 25 additions & 0 deletions packages/wallet-service/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3205,3 +3205,28 @@ export const getAddressAtIndex = async (
seqnum: addresses[0].seqnum,
}
};

/**
* Check if a wallet has any transactions on addresses with index > 0
*
* @param mysql - Database connection
* @param walletId - The wallet id to search for
*
* @returns True if there are transactions on addresses with index > 0, false otherwise
*/
export const hasTransactionsOnNonFirstAddress = async (
mysql: ServerlessMysql,
walletId: string,
): Promise<boolean> => {
const results: DbSelectResult = await mysql.query(
`SELECT 1
FROM \`address\`
WHERE \`wallet_id\` = ?
AND \`index\` > 0
AND \`transactions\` > 0
LIMIT 1`,
[walletId],
);

return results.length > 0;
};
78 changes: 78 additions & 0 deletions packages/wallet-service/tests/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3802,3 +3802,81 @@ describe('markUtxosWithProposalId - improved query performance', () => {
expect(markedUtxos[0].txProposalIndex).toBe(0);
});
});

describe('hasTransactionsOnNonFirstAddress', () => {
it('should return false when wallet has no addresses', async () => {
expect.hasAssertions();

const walletId = 'test-wallet';
await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5);

const result = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId);

expect(result).toBe(false);
});

it('should return false when wallet only has address at index 0', async () => {
expect.hasAssertions();

const walletId = 'test-wallet';
await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5);
await addToAddressTable(mysql, [
{ address: ADDRESSES[0], index: 0, walletId, transactions: 5 },
]);

const result = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId);

expect(result).toBe(false);
});

it('should return false when non-first addresses have no transactions', async () => {
expect.hasAssertions();

const walletId = 'test-wallet';
await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5);
await addToAddressTable(mysql, [
{ address: ADDRESSES[0], index: 0, walletId, transactions: 5 },
{ address: ADDRESSES[1], index: 1, walletId, transactions: 0 },
{ address: ADDRESSES[2], index: 2, walletId, transactions: 0 },
]);

const result = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId);

expect(result).toBe(false);
});

it('should return true when an address with index > 0 has transactions', async () => {
expect.hasAssertions();

const walletId = 'test-wallet';
await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5);
await addToAddressTable(mysql, [
{ address: ADDRESSES[0], index: 0, walletId, transactions: 5 },
{ address: ADDRESSES[1], index: 1, walletId, transactions: 3 },
]);

const result = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId);

expect(result).toBe(true);
});

it('should only consider addresses belonging to the specified wallet', async () => {
expect.hasAssertions();

const walletId1 = 'wallet-1';
const walletId2 = 'wallet-2';
await createWallet(mysql, walletId1, XPUBKEY, AUTH_XPUBKEY, 5);
await createWallet(mysql, walletId2, XPUBKEY, AUTH_XPUBKEY, 5);

await addToAddressTable(mysql, [
{ address: ADDRESSES[0], index: 0, walletId: walletId1, transactions: 5 },
{ address: ADDRESSES[1], index: 1, walletId: walletId2, transactions: 10 },
]);

const result1 = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId1);
const result2 = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId2);

expect(result1).toBe(false);
expect(result2).toBe(true);
});
});
140 changes: 140 additions & 0 deletions packages/wallet-service/tests/hasTxOutsideFirstAddr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { APIGatewayProxyResult } from 'aws-lambda';

import { get } from '@src/api/hasTxOutsideFirstAddr';
import { ApiError } from '@src/api/errors';
import { closeDbConnection, getDbConnection } from '@src/utils';
import {
ADDRESSES,
XPUBKEY,
AUTH_XPUBKEY,
addToAddressTable,
addToWalletTable,
cleanDatabase,
makeGatewayEventWithAuthorizer,
} from '@tests/utils';

const mysql = getDbConnection();

beforeEach(async () => {
await cleanDatabase(mysql);
});

afterAll(async () => {
await closeDbConnection(mysql);
});

describe('GET /wallet/addresses/has-transactions-outside-first-address', () => {
it('should return 404 when wallet is not found', async () => {
expect.hasAssertions();

const event = makeGatewayEventWithAuthorizer('non-existent-wallet', null);
const result = await get(event, null, null) as APIGatewayProxyResult;
const body = JSON.parse(result.body as string);

expect(result.statusCode).toBe(404);
expect(body.success).toBe(false);
expect(body.error).toBe(ApiError.WALLET_NOT_FOUND);
});

it('should return 400 when wallet is not ready', async () => {
expect.hasAssertions();

const walletId = 'wallet-not-ready';
await addToWalletTable(mysql, [{
id: walletId,
xpubkey: XPUBKEY,
authXpubkey: AUTH_XPUBKEY,
status: 'creating',
maxGap: 5,
createdAt: 10000,
readyAt: null,
}]);

const event = makeGatewayEventWithAuthorizer(walletId, null);
const result = await get(event, null, null) as APIGatewayProxyResult;
const body = JSON.parse(result.body as string);

expect(result.statusCode).toBe(400);
expect(body.success).toBe(false);
expect(body.error).toBe(ApiError.WALLET_NOT_READY);
});

it('should return hasTransactions=false when no non-first address has transactions', async () => {
expect.hasAssertions();

const walletId = 'my-wallet';
await addToWalletTable(mysql, [{
id: walletId,
xpubkey: XPUBKEY,
authXpubkey: AUTH_XPUBKEY,
status: 'ready',
maxGap: 5,
createdAt: 10000,
readyAt: 10001,
}]);
await addToAddressTable(mysql, [
{ address: ADDRESSES[0], index: 0, walletId, transactions: 10 },
{ address: ADDRESSES[1], index: 1, walletId, transactions: 0 },
]);

const event = makeGatewayEventWithAuthorizer(walletId, null);
const result = await get(event, null, null) as APIGatewayProxyResult;
const body = JSON.parse(result.body as string);

expect(result.statusCode).toBe(200);
expect(body.success).toBe(true);
expect(body.hasTransactions).toBe(false);
});

it('should return hasTransactions=true when a non-first address has transactions', async () => {
expect.hasAssertions();

const walletId = 'my-wallet';
await addToWalletTable(mysql, [{
id: walletId,
xpubkey: XPUBKEY,
authXpubkey: AUTH_XPUBKEY,
status: 'ready',
maxGap: 5,
createdAt: 10000,
readyAt: 10001,
}]);
await addToAddressTable(mysql, [
{ address: ADDRESSES[0], index: 0, walletId, transactions: 10 },
{ address: ADDRESSES[1], index: 1, walletId, transactions: 5 },
]);

const event = makeGatewayEventWithAuthorizer(walletId, null);
const result = await get(event, null, null) as APIGatewayProxyResult;
const body = JSON.parse(result.body as string);

expect(result.statusCode).toBe(200);
expect(body.success).toBe(true);
expect(body.hasTransactions).toBe(true);
});

it('should include CORS headers', async () => {
expect.hasAssertions();

const walletId = 'my-wallet';
await addToWalletTable(mysql, [{
id: walletId,
xpubkey: XPUBKEY,
authXpubkey: AUTH_XPUBKEY,
status: 'ready',
maxGap: 5,
createdAt: 10000,
readyAt: 10001,
}]);

const event = makeGatewayEventWithAuthorizer(walletId, null);
event.httpMethod = 'XXX';
const result = await get(event, null, null) as APIGatewayProxyResult;

expect(result.headers).toStrictEqual(
expect.objectContaining({
'Access-Control-Allow-Origin': '*',
}),
);
});
});