diff --git a/packages/wallet-service/serverless.yml b/packages/wallet-service/serverless.yml index 579c7413..6106d3eb 100644 --- a/packages/wallet-service/serverless.yml +++ b/packages/wallet-service/serverless.yml @@ -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: diff --git a/packages/wallet-service/src/api/hasTxOutsideFirstAddr.ts b/packages/wallet-service/src/api/hasTxOutsideFirstAddr.ts new file mode 100644 index 00000000..fd5a0944 --- /dev/null +++ b/packages/wallet-service/src/api/hasTxOutsideFirstAddr.ts @@ -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()); diff --git a/packages/wallet-service/src/db/index.ts b/packages/wallet-service/src/db/index.ts index 8a1d7207..08a5eec1 100644 --- a/packages/wallet-service/src/db/index.ts +++ b/packages/wallet-service/src/db/index.ts @@ -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 => { + 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; +}; diff --git a/packages/wallet-service/tests/db.test.ts b/packages/wallet-service/tests/db.test.ts index 28b59798..8baa7434 100644 --- a/packages/wallet-service/tests/db.test.ts +++ b/packages/wallet-service/tests/db.test.ts @@ -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); + }); +}); diff --git a/packages/wallet-service/tests/hasTxOutsideFirstAddr.test.ts b/packages/wallet-service/tests/hasTxOutsideFirstAddr.test.ts new file mode 100644 index 00000000..510ae5c1 --- /dev/null +++ b/packages/wallet-service/tests/hasTxOutsideFirstAddr.test.ts @@ -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': '*', + }), + ); + }); +});