From f01efaa0f85f5e7a92239141c879efded435dc98 Mon Sep 17 00:00:00 2001 From: David Li Date: Thu, 30 Oct 2025 11:38:18 -0400 Subject: [PATCH 1/5] New SQL query for parentSubaccount transfer endpoint --- .../__tests__/stores/transfer-table.test.ts | 354 ++++++++++++++++++ .../postgres/src/stores/transfer-table.ts | 111 ++++++ .../postgres/src/types/query-types.ts | 11 + .../api/v4/transfers-controller.ts | 35 +- 4 files changed, 491 insertions(+), 20 deletions(-) diff --git a/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts b/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts index d5d6c84e745..ec254004a81 100644 --- a/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts @@ -14,6 +14,7 @@ import { seedData } from '../helpers/mock-generators'; import { createdDateTime, createdHeight, + defaultAddress, defaultAsset, defaultAsset2, defaultDeposit, @@ -29,6 +30,7 @@ import { defaultTransfer3, defaultWalletAddress, defaultWithdrawal, + isolatedSubaccountId, } from '../helpers/constants'; import Big from 'big.js'; import { CheckViolationError } from 'objection'; @@ -661,4 +663,356 @@ describe('Transfer store', () => { expect(netTransfers).toEqual('0'); }); }); + + describe('findAllToOrFromParentSubaccount', () => { + beforeEach(async () => { + await seedData(); + }); + + beforeAll(async () => { + await migrate(); + }); + + afterEach(async () => { + await clearData(); + }); + + afterAll(async () => { + await teardown(); + }); + + it('Successfully excludes transfers between child subaccounts of the same parent', async () => { + // defaultSubaccount (subaccount 0) -> isolatedSubaccount (subaccount 128) + // Both have parent subaccount 0 (0 % 128 = 0, 128 % 128 = 0) + const sameParentTransfer: TransferCreateObject = { + senderSubaccountId: defaultSubaccountId, + recipientSubaccountId: isolatedSubaccountId, + assetId: defaultAsset.id, + size: '100', + eventId: defaultTendermintEventId, + transactionHash: '', + createdAt: createdDateTime.toISO(), + createdAtHeight: createdHeight, + }; + + // defaultSubaccount (parent 0) -> defaultSubaccount2 (parent 1) + const differentParentTransfer: TransferCreateObject = { + senderSubaccountId: defaultSubaccountId, + recipientSubaccountId: defaultSubaccountId2, + assetId: defaultAsset.id, + size: '200', + eventId: defaultTendermintEventId2, + transactionHash: '', + createdAt: createdDateTime.toISO(), + createdAtHeight: createdHeight, + }; + + await Promise.all([ + TransferTable.create(sameParentTransfer), + TransferTable.create(differentParentTransfer), + ]); + + const subaccountIds = [defaultSubaccountId, isolatedSubaccountId]; + + const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: subaccountIds, + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 100, + }, + [], + ); + + expect(transfers.length).toEqual(1); + expect(transfers[0]).toEqual(expect.objectContaining(differentParentTransfer)); + }); + + it('Successfully includes transfers from different addresses', async () => { + const crossAddressTransfer: TransferCreateObject = { + senderSubaccountId: defaultSubaccountId2, + recipientSubaccountId: defaultSubaccountId, + assetId: defaultAsset.id, + size: '150', + eventId: defaultTendermintEventId, + transactionHash: '', + createdAt: createdDateTime.toISO(), + createdAtHeight: createdHeight, + }; + + await TransferTable.create(crossAddressTransfer); + + const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [defaultSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 100, + }, + [], + ); + + expect(transfers.length).toEqual(1); + expect(transfers[0]).toEqual(expect.objectContaining(crossAddressTransfer)); + }); + + it('Successfully includes deposits to child subaccounts', async () => { + await WalletTable.create({ + address: defaultWalletAddress, + totalTradingRewards: '0', + totalVolume: '0', + }); + + const deposit: TransferCreateObject = { + senderWalletAddress: defaultWalletAddress, + recipientSubaccountId: isolatedSubaccountId, + assetId: defaultAsset.id, + size: '500', + eventId: defaultTendermintEventId, + transactionHash: '', + createdAt: createdDateTime.toISO(), + createdAtHeight: createdHeight, + }; + + await TransferTable.create(deposit); + + const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [isolatedSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 100, + }, + [], + ); + + expect(transfers.length).toEqual(1); + expect(transfers[0]).toEqual(expect.objectContaining(deposit)); + }); + + it('Successfully includes withdrawals from child subaccounts', async () => { + await WalletTable.create({ + address: defaultWalletAddress, + totalTradingRewards: '0', + totalVolume: '0', + }); + + const withdrawal: TransferCreateObject = { + senderSubaccountId: isolatedSubaccountId, + recipientWalletAddress: defaultWalletAddress, + assetId: defaultAsset.id, + size: '300', + eventId: defaultTendermintEventId, + transactionHash: '', + createdAt: createdDateTime.toISO(), + createdAtHeight: createdHeight, + }; + + await TransferTable.create(withdrawal); + + const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [isolatedSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 100, + }, + [], + ); + + expect(transfers.length).toEqual(1); + expect(transfers[0]).toEqual(expect.objectContaining(withdrawal)); + }); + + // it('Successfully respects limit parameter', async () => { + // const transfers: TransferCreateObject[] = []; + // for (let i = 0; i < 5; i++) { + // const eventIdBuffer = Buffer.from(defaultTendermintEventId); + // eventIdBuffer.writeUInt32BE(i, eventIdBuffer.length - 4); + + // transfers.push({ + // senderSubaccountId: defaultSubaccountId, + // recipientSubaccountId: defaultSubaccountId2, + // assetId: defaultAsset.id, + // size: `${i + 1}`, + // eventId: eventIdBuffer, + // transactionHash: `hash${i}`, + // createdAt: createdDateTime.plus({ minutes: i }).toISO(), + // createdAtHeight: (parseInt(createdHeight, 10) + i).toString(), + // }); + // } + + // await Promise.all(transfers.map((t) => TransferTable.create(t))); + + // const { results: resultTransfers } = await TransferTable.findAllToOrFromParentSubaccount( + // { + // subaccountId: [defaultSubaccountId], + // address: defaultAddress, + // parentSubaccountNumber: 0, + // limit: 3, + // }, + // [], + // ); + + // expect(resultTransfers.length).toEqual(3); + // }); + + // it('Successfully finds all transfers to and from parent subaccount using pagination', async () => { + // const transfers: TransferCreateObject[] = []; + // for (let i = 0; i < 4; i++) { + // const eventIdBuffer = Buffer.from(defaultTendermintEventId); + // eventIdBuffer.writeUInt32BE(i, eventIdBuffer.length - 4); + + // transfers.push({ + // senderSubaccountId: defaultSubaccountId, + // recipientSubaccountId: defaultSubaccountId2, + // assetId: defaultAsset.id, + // size: `${i + 1}`, + // eventId: eventIdBuffer, + // transactionHash: `hash${i}`, + // createdAt: createdDateTime.plus({ minutes: i }).toISO(), + // createdAtHeight: (parseInt(createdHeight, 10) + i).toString(), + // }); + // } + + // await Promise.all(transfers.map((t) => TransferTable.create(t))); + + // const responsePageOne = await TransferTable.findAllToOrFromParentSubaccount( + // { + // subaccountId: [defaultSubaccountId], + // address: defaultAddress, + // parentSubaccountNumber: 0, + // limit: 2, + // page: 1, + // }, + // [], + // { orderBy: [[TransferColumns.id, Ordering.ASC]] }, + // ); + + // expect(responsePageOne.results.length).toEqual(2); + // expect(responsePageOne.offset).toEqual(0); + // expect(responsePageOne.total).toEqual(4); + // expect(responsePageOne.limit).toEqual(2); + + // const responsePageTwo = await TransferTable.findAllToOrFromParentSubaccount( + // { + // subaccountId: [defaultSubaccountId], + // address: defaultAddress, + // parentSubaccountNumber: 0, + // limit: 2, + // page: 2, + // }, + // [], + // { orderBy: [[TransferColumns.id, Ordering.ASC]] }, + // ); + + // expect(responsePageTwo.results.length).toEqual(2); + // expect(responsePageTwo.offset).toEqual(2); + // expect(responsePageTwo.total).toEqual(4); + // expect(responsePageTwo.limit).toEqual(2); + // }); + + it('Successfully finds all transfers before or at the height', async () => { + const transfer1: TransferCreateObject = { + senderSubaccountId: defaultSubaccountId, + recipientSubaccountId: defaultSubaccountId2, + assetId: defaultAsset.id, + size: '100', + eventId: defaultTendermintEventId, + transactionHash: '', + createdAt: createdDateTime.toISO(), + createdAtHeight: '10', + }; + + const transfer2: TransferCreateObject = { + senderSubaccountId: defaultSubaccountId, + recipientSubaccountId: defaultSubaccountId2, + assetId: defaultAsset.id, + size: '200', + eventId: defaultTendermintEventId2, + transactionHash: '', + createdAt: createdDateTime.plus({ minutes: 1 }).toISO(), + createdAtHeight: '20', + }; + + await Promise.all([ + TransferTable.create(transfer1), + TransferTable.create(transfer2), + ]); + + const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [defaultSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 100, + createdBeforeOrAtHeight: '10', + }, + [], + ); + + expect(transfers.length).toEqual(1); + expect(transfers[0]).toEqual(expect.objectContaining(transfer1)); + }); + + it('Successfully finds all transfers before or at the time', async () => { + const createdAt1 = '2000-01-01T00:00:00.000Z'; + const createdAt2 = '2000-01-02T00:00:00.000Z'; + + const transfer1: TransferCreateObject = { + senderSubaccountId: defaultSubaccountId, + recipientSubaccountId: defaultSubaccountId2, + assetId: defaultAsset.id, + size: '100', + eventId: defaultTendermintEventId, + transactionHash: '', + createdAt: createdAt1, + createdAtHeight: createdHeight, + }; + + const transfer2: TransferCreateObject = { + senderSubaccountId: defaultSubaccountId, + recipientSubaccountId: defaultSubaccountId2, + assetId: defaultAsset.id, + size: '200', + eventId: defaultTendermintEventId2, + transactionHash: '', + createdAt: createdAt2, + createdAtHeight: (parseInt(createdHeight, 10) + 1).toString(), + }; + + await Promise.all([ + TransferTable.create(transfer1), + TransferTable.create(transfer2), + ]); + + const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [defaultSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 100, + createdBeforeOrAt: createdAt1, + }, + [], + ); + + expect(transfers.length).toEqual(1); + expect(transfers[0]).toEqual(expect.objectContaining(transfer1)); + }); + + it('Successfully returns empty results when no transfers match criteria', async () => { + const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [defaultSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 100, + }, + [], + ); + + expect(transfers.length).toEqual(0); + }); + }); }); diff --git a/indexer/packages/postgres/src/stores/transfer-table.ts b/indexer/packages/postgres/src/stores/transfer-table.ts index c625dacaebb..50cc42cf568 100644 --- a/indexer/packages/postgres/src/stores/transfer-table.ts +++ b/indexer/packages/postgres/src/stores/transfer-table.ts @@ -20,6 +20,7 @@ import { Options, QueryableField, ToAndFromSubaccountTransferQueryConfig, + ParentSubaccountTransferQueryConfig, SubaccountAssetNetTransferMap, PaginationFromDatabase, } from '../types'; @@ -330,6 +331,116 @@ export async function findAllToOrFromSubaccountId( }; } +/** + * Finds all transfers to or from a parent subaccount and its children, + * excluding transfers between child subaccounts of the same parent. + */ +export async function findAllToOrFromParentSubaccount( + { + subaccountId, + address, + parentSubaccountNumber, + limit, + createdBeforeOrAtHeight, + createdBeforeOrAt, + page, + }: ParentSubaccountTransferQueryConfig, + requiredFields: QueryableField[], + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise> { + verifyAllRequiredFields( + { + [QueryableField.LIMIT]: limit, + [QueryableField.SUBACCOUNT_ID]: subaccountId, + [QueryableField.ADDRESS]: address, + [QueryableField.PARENT_SUBACCOUNT_NUMBER]: parentSubaccountNumber, + [QueryableField.CREATED_BEFORE_OR_AT_HEIGHT]: createdBeforeOrAtHeight, + [QueryableField.CREATED_BEFORE_OR_AT]: createdBeforeOrAt, + } as QueryConfig, + requiredFields, + ); + + let baseQuery: QueryBuilder = setupBaseQuery( + TransferModel, + options, + ); + + // Join with subaccounts to filter same-parent transfers + baseQuery = baseQuery + .leftJoin( + 'subaccounts as sender_sa', + 'transfers.senderSubaccountId', + 'sender_sa.id', + ) + .leftJoin( + 'subaccounts as recipient_sa', + 'transfers.recipientSubaccountId', + 'recipient_sa.id', + ) + // Exclude transfers where both sender and recipient are child subaccounts of the same parent + .whereRaw(` + NOT ( + transfers."senderSubaccountId" IS NOT NULL + AND transfers."recipientSubaccountId" IS NOT NULL + AND sender_sa.address = recipient_sa.address + AND (sender_sa."subaccountNumber" % 128) = (recipient_sa."subaccountNumber" % 128) + ) + `) + .select('transfers.*'); + + // Filter by child subaccount IDs + baseQuery = baseQuery.where((queryBuilder) => { + // eslint-disable-next-line no-void + void queryBuilder.whereIn(TransferColumns.recipientSubaccountId, subaccountId) + .orWhereIn(TransferColumns.senderSubaccountId, subaccountId); + }); + + if (createdBeforeOrAtHeight !== undefined) { + baseQuery = baseQuery.where( + TransferColumns.createdAtHeight, + '<=', + createdBeforeOrAtHeight, + ); + } + + if (createdBeforeOrAt !== undefined) { + baseQuery = baseQuery.where(TransferColumns.createdAt, '<=', createdBeforeOrAt); + } + + if (options.orderBy !== undefined) { + for (const [column, order] of options.orderBy) { + baseQuery = baseQuery.orderBy( + column, + order, + ); + } + } + + if (limit !== undefined && page === undefined) { + baseQuery = baseQuery.limit(limit); + } + + // Pagination + if (page !== undefined && limit !== undefined) { + const currentPage: number = Math.max(1, page); + const offset: number = (currentPage - 1) * limit; + + const count: { count?: string } = await baseQuery.clone().clearOrder().count({ count: '*' }).first() as unknown as { count?: string }; + baseQuery = baseQuery.offset(offset).limit(limit); + + return { + results: await baseQuery.returning('*'), + limit, + offset, + total: parseInt(count.count ?? '0', 10), + }; + } + + return { + results: await baseQuery.returning('*'), + }; +} + function convertToSubaccountAssetMap( transfers: SubaccountAssetNetTransfer[], ): SubaccountAssetNetTransferMap { diff --git a/indexer/packages/postgres/src/types/query-types.ts b/indexer/packages/postgres/src/types/query-types.ts index f1e48f237c1..5b32284a48a 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -105,6 +105,7 @@ export enum QueryableField { SVM_ADDRESS = 'svm_address', EVM_ADDRESS = 'evm_address', DYDX_ADDRESS = 'dydx_address', + PARENT_SUBACCOUNT_NUMBER = 'parentSubaccountNumber', } export interface QueryConfig { @@ -254,6 +255,16 @@ export interface ToAndFromSubaccountTransferQueryConfig extends QueryConfig { [QueryableField.CREATED_AFTER]?: string | undefined, } +export interface ParentSubaccountTransferQueryConfig extends QueryConfig { + [QueryableField.SUBACCOUNT_ID]: string[], + [QueryableField.ADDRESS]: string, + [QueryableField.PARENT_SUBACCOUNT_NUMBER]: number, + [QueryableField.LIMIT]?: number, + [QueryableField.CREATED_BEFORE_OR_AT_HEIGHT]?: string, + [QueryableField.CREATED_BEFORE_OR_AT]?: string, + [QueryableField.PAGE]?: number, +} + export interface OraclePriceQueryConfig extends QueryConfig { [QueryableField.ID]?: string[], [QueryableField.MARKET_ID]?: number[], diff --git a/indexer/services/comlink/src/controllers/api/v4/transfers-controller.ts b/indexer/services/comlink/src/controllers/api/v4/transfers-controller.ts index 3accbc6f68e..bc826150959 100644 --- a/indexer/services/comlink/src/controllers/api/v4/transfers-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/transfers-controller.ts @@ -148,8 +148,7 @@ class TransfersController extends Controller { @Query() createdBeforeOrAt?: IsoString, @Query() page?: number, ): Promise { - - // get all child subaccountIds for the parent subaccount number + // get all child subaccountIds for the parent subaccount number const subaccountIds: string[] = getChildSubaccountNums(parentSubaccountNumber).map( (childSubaccountNumber: number) => SubaccountTable.uuid(address, childSubaccountNumber), ); @@ -164,21 +163,22 @@ class TransfersController extends Controller { SubaccountFromDatabase[] | undefined, PaginationFromDatabase, AssetById, - ] = await - Promise.all([ + ] = await Promise.all([ SubaccountTable.findAll( { id: subaccountIds }, [], ), - TransferTable.findAllToOrFromSubaccountId( + TransferTable.findAllToOrFromParentSubaccount( { - subaccountId: subaccountIds, - limit, - createdBeforeOrAtHeight: createdBeforeOrAtHeight + [QueryableField.SUBACCOUNT_ID]: subaccountIds, + [QueryableField.ADDRESS]: address, + [QueryableField.PARENT_SUBACCOUNT_NUMBER]: parentSubaccountNumber, + [QueryableField.LIMIT]: limit, + [QueryableField.CREATED_BEFORE_OR_AT_HEIGHT]: createdBeforeOrAtHeight ? createdBeforeOrAtHeight.toString() : undefined, - createdBeforeOrAt, - page, + [QueryableField.CREATED_BEFORE_OR_AT]: createdBeforeOrAt, + [QueryableField.PAGE]: page, }, [QueryableField.LIMIT], { @@ -193,11 +193,13 @@ class TransfersController extends Controller { ), getAssetById(), ]); + if (subaccounts === undefined || subaccounts.length === 0) { throw new NotFoundError( `No subaccount found with address ${address} and parentSubaccountNumber ${parentSubaccountNumber}`, ); } + const recipientSubaccountIds: string[] = _ .map(transfers, TransferColumns.recipientSubaccountId) .filter( @@ -215,7 +217,7 @@ class TransfersController extends Controller { ]); const idToSubaccount: SubaccountById = await idToSubaccountFromSubaccountIds(allSubaccountIds); - const transfersWithParentSubaccount: ParentSubaccountTransferResponseObject[] = transfers.map( + const transfersResponse: ParentSubaccountTransferResponseObject[] = transfers.map( (transfer: TransferFromDatabase) => { return transferToParentSubaccountResponseObject( transfer, @@ -225,16 +227,9 @@ class TransfersController extends Controller { parentSubaccountNumber); }); - // Filter out transfers where the sender and recipient parent subaccount numbers are the same - const transfersFiltered: - ParentSubaccountTransferResponseObject[] = transfersWithParentSubaccount.filter( - (transfer) => { - return transfer.sender.address !== transfer.recipient.address || - transfer.sender.parentSubaccountNumber !== transfer.recipient.parentSubaccountNumber; - }); - + // No filtering needed - it's done at the SQL level now return { - transfers: transfersFiltered, + transfers: transfersResponse, pageSize, totalResults: total, offset, From 100d95a40ede0cab0a3465b352ea2b6ba8d891b1 Mon Sep 17 00:00:00 2001 From: David Li Date: Thu, 30 Oct 2025 14:28:03 -0400 Subject: [PATCH 2/5] Add transfer table tests --- .../__tests__/stores/transfer-table.test.ts | 243 ++++++++++-------- .../postgres/src/stores/transfer-table.ts | 8 +- 2 files changed, 146 insertions(+), 105 deletions(-) diff --git a/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts b/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts index ec254004a81..861f5e98981 100644 --- a/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts @@ -1,6 +1,8 @@ import { + BlockCreateObject, Ordering, SubaccountAssetNetTransferMap, + TendermintEventCreateObject, TransferColumns, TransferCreateObject, TransferFromDatabase, @@ -35,7 +37,7 @@ import { import Big from 'big.js'; import { CheckViolationError } from 'objection'; import { DateTime } from 'luxon'; -import { USDC_ASSET_ID } from '../../src'; +import { BlockTable, TendermintEventTable, USDC_ASSET_ID } from '../../src'; describe('Transfer store', () => { beforeEach(async () => { @@ -665,22 +667,6 @@ describe('Transfer store', () => { }); describe('findAllToOrFromParentSubaccount', () => { - beforeEach(async () => { - await seedData(); - }); - - beforeAll(async () => { - await migrate(); - }); - - afterEach(async () => { - await clearData(); - }); - - afterAll(async () => { - await teardown(); - }); - it('Successfully excludes transfers between child subaccounts of the same parent', async () => { // defaultSubaccount (subaccount 0) -> isolatedSubaccount (subaccount 128) // Both have parent subaccount 0 (0 % 128 = 0, 128 % 128 = 0) @@ -824,93 +810,142 @@ describe('Transfer store', () => { expect(transfers[0]).toEqual(expect.objectContaining(withdrawal)); }); - // it('Successfully respects limit parameter', async () => { - // const transfers: TransferCreateObject[] = []; - // for (let i = 0; i < 5; i++) { - // const eventIdBuffer = Buffer.from(defaultTendermintEventId); - // eventIdBuffer.writeUInt32BE(i, eventIdBuffer.length - 4); - - // transfers.push({ - // senderSubaccountId: defaultSubaccountId, - // recipientSubaccountId: defaultSubaccountId2, - // assetId: defaultAsset.id, - // size: `${i + 1}`, - // eventId: eventIdBuffer, - // transactionHash: `hash${i}`, - // createdAt: createdDateTime.plus({ minutes: i }).toISO(), - // createdAtHeight: (parseInt(createdHeight, 10) + i).toString(), - // }); - // } - - // await Promise.all(transfers.map((t) => TransferTable.create(t))); - - // const { results: resultTransfers } = await TransferTable.findAllToOrFromParentSubaccount( - // { - // subaccountId: [defaultSubaccountId], - // address: defaultAddress, - // parentSubaccountNumber: 0, - // limit: 3, - // }, - // [], - // ); - - // expect(resultTransfers.length).toEqual(3); - // }); - - // it('Successfully finds all transfers to and from parent subaccount using pagination', async () => { - // const transfers: TransferCreateObject[] = []; - // for (let i = 0; i < 4; i++) { - // const eventIdBuffer = Buffer.from(defaultTendermintEventId); - // eventIdBuffer.writeUInt32BE(i, eventIdBuffer.length - 4); - - // transfers.push({ - // senderSubaccountId: defaultSubaccountId, - // recipientSubaccountId: defaultSubaccountId2, - // assetId: defaultAsset.id, - // size: `${i + 1}`, - // eventId: eventIdBuffer, - // transactionHash: `hash${i}`, - // createdAt: createdDateTime.plus({ minutes: i }).toISO(), - // createdAtHeight: (parseInt(createdHeight, 10) + i).toString(), - // }); - // } - - // await Promise.all(transfers.map((t) => TransferTable.create(t))); - - // const responsePageOne = await TransferTable.findAllToOrFromParentSubaccount( - // { - // subaccountId: [defaultSubaccountId], - // address: defaultAddress, - // parentSubaccountNumber: 0, - // limit: 2, - // page: 1, - // }, - // [], - // { orderBy: [[TransferColumns.id, Ordering.ASC]] }, - // ); - - // expect(responsePageOne.results.length).toEqual(2); - // expect(responsePageOne.offset).toEqual(0); - // expect(responsePageOne.total).toEqual(4); - // expect(responsePageOne.limit).toEqual(2); - - // const responsePageTwo = await TransferTable.findAllToOrFromParentSubaccount( - // { - // subaccountId: [defaultSubaccountId], - // address: defaultAddress, - // parentSubaccountNumber: 0, - // limit: 2, - // page: 2, - // }, - // [], - // { orderBy: [[TransferColumns.id, Ordering.ASC]] }, - // ); - - // expect(responsePageTwo.results.length).toEqual(2); - // expect(responsePageTwo.offset).toEqual(2); - // expect(responsePageTwo.total).toEqual(4); - // expect(responsePageTwo.limit).toEqual(2); - // }); + it('Successfully respects limit parameter', async () => { + // Create 5 blocks first + const blocks: BlockCreateObject[] = []; + for (let i = 0; i < 5; i++) { + blocks.push({ + blockHeight: (3 + i).toString(), + time: createdDateTime.plus({ minutes: i }).toISO(), + }); + } + await Promise.all(blocks.map((b) => BlockTable.create(b))); + + // Create 5 TendermintEvents + const events: TendermintEventCreateObject[] = []; + for (let i = 0; i < 5; i++) { + events.push({ + blockHeight: (3 + i).toString(), + transactionIndex: 0, + eventIndex: 0, + }); + } + await Promise.all(events.map((e) => TendermintEventTable.create(e))); + + // Create 5 transfers + const transfers: TransferCreateObject[] = []; + for (let i = 0; i < 5; i++) { + const eventId = TendermintEventTable.createEventId( + events[i].blockHeight, + events[i].transactionIndex, + events[i].eventIndex, + ); + + transfers.push({ + senderSubaccountId: defaultSubaccountId, + recipientSubaccountId: defaultSubaccountId2, + assetId: defaultAsset.id, + size: `${i + 1}`, + eventId, + transactionHash: `hash${i}`, + createdAt: createdDateTime.plus({ minutes: i }).toISO(), + createdAtHeight: (parseInt(createdHeight, 10) + i).toString(), + }); + } + await Promise.all(transfers.map((t) => TransferTable.create(t))); + + const { results: resultTransfers } = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [defaultSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 3, + }, + [], + ); + + expect(resultTransfers.length).toEqual(3); + }); + + it('Successfully finds all transfers to and from parent subaccount using pagination', async () => { + // Create 4 blocks first + const blocks: BlockCreateObject[] = []; + for (let i = 0; i < 4; i++) { + blocks.push({ + blockHeight: (3 + i).toString(), + time: createdDateTime.plus({ minutes: i }).toISO(), + }); + } + await Promise.all(blocks.map((b) => BlockTable.create(b))); + + // Create 4 TendermintEvents + const events: TendermintEventCreateObject[] = []; + for (let i = 0; i < 4; i++) { + events.push({ + blockHeight: (3 + i).toString(), + transactionIndex: 0, + eventIndex: 0, + }); + } + await Promise.all(events.map((e) => TendermintEventTable.create(e))); + + // Create 4 transfers + const transfers: TransferCreateObject[] = []; + for (let i = 0; i < 4; i++) { + const eventId = TendermintEventTable.createEventId( + events[i].blockHeight, + events[i].transactionIndex, + events[i].eventIndex, + ); + + transfers.push({ + senderSubaccountId: defaultSubaccountId, + recipientSubaccountId: defaultSubaccountId2, + assetId: defaultAsset.id, + size: `${i + 1}`, + eventId, + transactionHash: `hash${i}`, + createdAt: createdDateTime.plus({ minutes: i }).toISO(), + createdAtHeight: (parseInt(createdHeight, 10) + i).toString(), + }); + } + + await Promise.all(transfers.map((t) => TransferTable.create(t))); + + const responsePageOne = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [defaultSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 2, + page: 1, + }, + [], + { orderBy: [[TransferColumns.id, Ordering.ASC]] }, + ); + + expect(responsePageOne.results.length).toEqual(2); + expect(responsePageOne.offset).toEqual(0); + expect(responsePageOne.total).toEqual(4); + expect(responsePageOne.limit).toEqual(2); + + const responsePageTwo = await TransferTable.findAllToOrFromParentSubaccount( + { + subaccountId: [defaultSubaccountId], + address: defaultAddress, + parentSubaccountNumber: 0, + limit: 2, + page: 2, + }, + [], + { orderBy: [[TransferColumns.id, Ordering.ASC]] }, + ); + + expect(responsePageTwo.results.length).toEqual(2); + expect(responsePageTwo.offset).toEqual(2); + expect(responsePageTwo.total).toEqual(4); + expect(responsePageTwo.limit).toEqual(2); + }); it('Successfully finds all transfers before or at the height', async () => { const transfer1: TransferCreateObject = { diff --git a/indexer/packages/postgres/src/stores/transfer-table.ts b/indexer/packages/postgres/src/stores/transfer-table.ts index 50cc42cf568..ced17fd2734 100644 --- a/indexer/packages/postgres/src/stores/transfer-table.ts +++ b/indexer/packages/postgres/src/stores/transfer-table.ts @@ -425,7 +425,13 @@ export async function findAllToOrFromParentSubaccount( const currentPage: number = Math.max(1, page); const offset: number = (currentPage - 1) * limit; - const count: { count?: string } = await baseQuery.clone().clearOrder().count({ count: '*' }).first() as unknown as { count?: string }; + const count: { count?: string } = await baseQuery + .clone() + .clearSelect() // Add this + .clearOrder() + .count('transfers.id as count') + .first() as unknown as { count?: string }; + baseQuery = baseQuery.offset(offset).limit(limit); return { From 27a2ba67ca8991fa321180548b8ab61917d02a5e Mon Sep 17 00:00:00 2001 From: David Li Date: Wed, 5 Nov 2025 14:10:32 -0500 Subject: [PATCH 3/5] New transfer parentSubaccountNumber endpoint --- .../indexer-build-and-push-dev-staging.yml | 1 + .../protocol-build-and-push-snapshot.yml | 1 + .github/workflows/protocol-build-and-push.yml | 1 + .../__tests__/stores/transfer-table.test.ts | 30 +- .../postgres/src/stores/transfer-table.ts | 14 +- .../postgres/src/types/query-types.ts | 2 - .../api/v4/transfers-controller.test.ts | 482 ++++++++---------- .../api/v4/transfers-controller.ts | 13 +- 8 files changed, 237 insertions(+), 307 deletions(-) diff --git a/.github/workflows/indexer-build-and-push-dev-staging.yml b/.github/workflows/indexer-build-and-push-dev-staging.yml index 87b597c0a62..d2ac3acf85c 100644 --- a/.github/workflows/indexer-build-and-push-dev-staging.yml +++ b/.github/workflows/indexer-build-and-push-dev-staging.yml @@ -6,6 +6,7 @@ on: # yamllint disable-line rule:truthy - main - 'release/indexer/v[0-9]+.[0-9]+.x' # e.g. release/indexer/v0.1.x - 'release/indexer/v[0-9]+.x' # e.g. release/indexer/v1.x + - davidli/transfers_endpoint # TODO(DEC-837): Customize github build and push to ECR by service with paths jobs: diff --git a/.github/workflows/protocol-build-and-push-snapshot.yml b/.github/workflows/protocol-build-and-push-snapshot.yml index ae94a69a836..3c2ebd71417 100644 --- a/.github/workflows/protocol-build-and-push-snapshot.yml +++ b/.github/workflows/protocol-build-and-push-snapshot.yml @@ -6,6 +6,7 @@ on: # yamllint disable-line rule:truthy - main - 'release/protocol/v[0-9]+.[0-9]+.x' # e.g. release/protocol/v0.1.x - 'release/protocol/v[0-9]+.x' # e.g. release/protocol/v1.x + - davidli/transfers_endpoint jobs: build-and-push-snapshot-dev: diff --git a/.github/workflows/protocol-build-and-push.yml b/.github/workflows/protocol-build-and-push.yml index 3e9adfcf809..ec64fe1df77 100644 --- a/.github/workflows/protocol-build-and-push.yml +++ b/.github/workflows/protocol-build-and-push.yml @@ -6,6 +6,7 @@ on: # yamllint disable-line rule:truthy - main - 'release/protocol/v[0-9]+.[0-9]+.x' # e.g. release/protocol/v0.1.x - 'release/protocol/v[0-9]+.x' # e.g. release/protocol/v1.x + - davidli/transfers_endpoint jobs: build-and-push-dev: diff --git a/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts b/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts index 861f5e98981..27c6ab9db29 100644 --- a/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/transfer-table.test.ts @@ -16,7 +16,6 @@ import { seedData } from '../helpers/mock-generators'; import { createdDateTime, createdHeight, - defaultAddress, defaultAsset, defaultAsset2, defaultDeposit, @@ -698,13 +697,9 @@ describe('Transfer store', () => { TransferTable.create(differentParentTransfer), ]); - const subaccountIds = [defaultSubaccountId, isolatedSubaccountId]; - const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( { - subaccountId: subaccountIds, - address: defaultAddress, - parentSubaccountNumber: 0, + subaccountId: [defaultSubaccountId, isolatedSubaccountId], limit: 100, }, [], @@ -731,8 +726,6 @@ describe('Transfer store', () => { const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [defaultSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 100, }, [], @@ -765,8 +758,6 @@ describe('Transfer store', () => { const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [isolatedSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 100, }, [], @@ -799,8 +790,6 @@ describe('Transfer store', () => { const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [isolatedSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 100, }, [], @@ -811,7 +800,7 @@ describe('Transfer store', () => { }); it('Successfully respects limit parameter', async () => { - // Create 5 blocks first + // Create 5 blocks first const blocks: BlockCreateObject[] = []; for (let i = 0; i < 5; i++) { blocks.push({ @@ -852,13 +841,12 @@ describe('Transfer store', () => { createdAtHeight: (parseInt(createdHeight, 10) + i).toString(), }); } + await Promise.all(transfers.map((t) => TransferTable.create(t))); const { results: resultTransfers } = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [defaultSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 3, }, [], @@ -868,7 +856,7 @@ describe('Transfer store', () => { }); it('Successfully finds all transfers to and from parent subaccount using pagination', async () => { - // Create 4 blocks first + // Create 4 blocks first const blocks: BlockCreateObject[] = []; for (let i = 0; i < 4; i++) { blocks.push({ @@ -915,8 +903,6 @@ describe('Transfer store', () => { const responsePageOne = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [defaultSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 2, page: 1, }, @@ -932,8 +918,6 @@ describe('Transfer store', () => { const responsePageTwo = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [defaultSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 2, page: 2, }, @@ -978,8 +962,6 @@ describe('Transfer store', () => { const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [defaultSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 100, createdBeforeOrAtHeight: '10', }, @@ -1024,8 +1006,6 @@ describe('Transfer store', () => { const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [defaultSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 100, createdBeforeOrAt: createdAt1, }, @@ -1040,8 +1020,6 @@ describe('Transfer store', () => { const { results: transfers } = await TransferTable.findAllToOrFromParentSubaccount( { subaccountId: [defaultSubaccountId], - address: defaultAddress, - parentSubaccountNumber: 0, limit: 100, }, [], diff --git a/indexer/packages/postgres/src/stores/transfer-table.ts b/indexer/packages/postgres/src/stores/transfer-table.ts index ced17fd2734..0cae6792efc 100644 --- a/indexer/packages/postgres/src/stores/transfer-table.ts +++ b/indexer/packages/postgres/src/stores/transfer-table.ts @@ -332,14 +332,20 @@ export async function findAllToOrFromSubaccountId( } /** - * Finds all transfers to or from a parent subaccount and its children, + * Finds all transfers to or from child subaccounts of a parent subaccount, * excluding transfers between child subaccounts of the same parent. + * + * @param subaccountId - Array of all child subaccount IDs for the parent + * @param limit - Maximum number of results to return + * @param createdBeforeOrAtHeight - Filter transfers created at or before this height + * @param createdBeforeOrAt - Filter transfers created at or before this time + * @param page - Page number for pagination + * + * @returns Paginated list of transfers with same-parent transfers filtered out */ export async function findAllToOrFromParentSubaccount( { subaccountId, - address, - parentSubaccountNumber, limit, createdBeforeOrAtHeight, createdBeforeOrAt, @@ -352,8 +358,6 @@ export async function findAllToOrFromParentSubaccount( { [QueryableField.LIMIT]: limit, [QueryableField.SUBACCOUNT_ID]: subaccountId, - [QueryableField.ADDRESS]: address, - [QueryableField.PARENT_SUBACCOUNT_NUMBER]: parentSubaccountNumber, [QueryableField.CREATED_BEFORE_OR_AT_HEIGHT]: createdBeforeOrAtHeight, [QueryableField.CREATED_BEFORE_OR_AT]: createdBeforeOrAt, } as QueryConfig, diff --git a/indexer/packages/postgres/src/types/query-types.ts b/indexer/packages/postgres/src/types/query-types.ts index 5b32284a48a..0a6677eb828 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -257,8 +257,6 @@ export interface ToAndFromSubaccountTransferQueryConfig extends QueryConfig { export interface ParentSubaccountTransferQueryConfig extends QueryConfig { [QueryableField.SUBACCOUNT_ID]: string[], - [QueryableField.ADDRESS]: string, - [QueryableField.PARENT_SUBACCOUNT_NUMBER]: number, [QueryableField.LIMIT]?: number, [QueryableField.CREATED_BEFORE_OR_AT_HEIGHT]?: string, [QueryableField.CREATED_BEFORE_OR_AT]?: string, diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/transfers-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/transfers-controller.test.ts index 5f6548bdac2..a538cfc2b9e 100644 --- a/indexer/services/comlink/__tests__/controllers/api/v4/transfers-controller.test.ts +++ b/indexer/services/comlink/__tests__/controllers/api/v4/transfers-controller.test.ts @@ -1,8 +1,12 @@ import { + BlockCreateObject, + BlockTable, dbHelpers, IsoString, SubaccountTable, SubaccountUsernamesTable, + TendermintEventCreateObject, + TendermintEventTable, testConstants, testMocks, TransferCreateObject, @@ -19,12 +23,7 @@ import { } from '../../../../src/types'; import request from 'supertest'; import { getQueryString, sendRequest } from '../../../helpers/helpers'; -import { - createdDateTime, createdHeight, - defaultAsset, defaultSubaccount2Num0, - defaultTendermintEventId4, - defaultWalletAddress, isolatedSubaccountId, -} from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants'; +import { defaultWalletAddress } from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants'; import Big from 'big.js'; const defaultWallet = { @@ -447,6 +446,95 @@ describe('transfers-controller#V4', () => { }); }); + it('Get /transfers/parentSubaccountNumber returns more than 10 transfers after filtering', async () => { + await testMocks.seedData(); + await WalletTable.create(defaultWallet); + await SubaccountTable.create(testConstants.defaultSubaccount2Num0); + + // Create 50 blocks for all transfers + const blocks: BlockCreateObject[] = []; + for (let i = 0; i < 50; i++) { + blocks.push({ + blockHeight: (3 + i).toString(), + time: testConstants.createdDateTime.plus({ minutes: i }).toISO(), + }); + } + await Promise.all(blocks.map((b) => BlockTable.create(b))); + + // Create 50 TendermintEvents + const events: TendermintEventCreateObject[] = []; + for (let i = 0; i < 50; i++) { + events.push({ + blockHeight: (3 + i).toString(), + transactionIndex: 0, + eventIndex: 0, + }); + } + await Promise.all(events.map((e) => TendermintEventTable.create(e))); + + // Create 35 same-parent transfers that should be filtered out + // These are transfers between defaultSubaccount (0) and isolatedSubaccount (128) + // Both have parent 0 (0 % 128 = 0, 128 % 128 = 0) + const sameParentTransfers: TransferCreateObject[] = []; + for (let i = 0; i < 35; i++) { + const eventId = TendermintEventTable.createEventId( + events[i].blockHeight, + events[i].transactionIndex, + events[i].eventIndex, + ); + + sameParentTransfers.push({ + senderSubaccountId: testConstants.defaultSubaccountId, + recipientSubaccountId: testConstants.isolatedSubaccountId, + assetId: testConstants.defaultAsset.id, + size: `${i + 1}`, + eventId, + transactionHash: `same_parent_${i}`, + createdAt: testConstants.createdDateTime.plus({ minutes: i }).toISO(), + createdAtHeight: (parseInt(testConstants.createdHeight, 10) + i).toString(), + }); + } + await Promise.all(sameParentTransfers.map((t) => TransferTable.create(t))); + + // Create 15 cross-parent transfers (parent 0 -> parent 1) + // These should ALL be returned + const crossParentTransfers: TransferCreateObject[] = []; + for (let i = 0; i < 15; i++) { + const eventId = TendermintEventTable.createEventId( + events[35 + i].blockHeight, + events[35 + i].transactionIndex, + events[35 + i].eventIndex, + ); + + crossParentTransfers.push({ + senderSubaccountId: testConstants.defaultSubaccountId, + recipientSubaccountId: testConstants.defaultSubaccountId2, + assetId: testConstants.defaultAsset.id, + size: `${i + 100}`, + eventId, + transactionHash: `cross_parent_${i}`, + createdAt: testConstants.createdDateTime.plus({ minutes: 35 + i }).toISO(), + createdAtHeight: (parseInt(testConstants.createdHeight, 10) + 35 + i).toString(), + }); + } + await Promise.all(crossParentTransfers.map((t) => TransferTable.create(t))); + + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/transfers/parentSubaccountNumber?address=${testConstants.defaultAddress}` + + `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}`, + }); + + // Should return all 15 cross-parent transfers + expect(response.body.transfers.length).toEqual(15); + + // Verify none of the same-parent transfers are included + response.body.transfers.forEach((transfer: ParentSubaccountTransferResponseObject) => { + expect(transfer.sender.parentSubaccountNumber) + .not.toEqual(transfer.recipient.parentSubaccountNumber); + }); + }); + it('Get /transfers/parentSubaccountNumber returns transfers/deposits/withdrawals', async () => { await testMocks.seedData(); const transfer2: TransferCreateObject = { @@ -455,14 +543,12 @@ describe('transfers-controller#V4', () => { assetId: testConstants.defaultAsset2.id, size: '5', eventId: testConstants.defaultTendermintEventId2, - transactionHash: '', // TODO: Add a real transaction Hash + transactionHash: '', createdAt: testConstants.createdDateTime.toISO(), createdAtHeight: testConstants.createdHeight, }; await WalletTable.create(defaultWallet); - await Promise.all([ - SubaccountTable.create(defaultSubaccount2Num0), - ]); + await SubaccountTable.create(testConstants.defaultSubaccount2Num0); await Promise.all([ TransferTable.create(testConstants.defaultTransfer), TransferTable.create(transfer2), @@ -474,7 +560,7 @@ describe('transfers-controller#V4', () => { const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/transfers/parentSubaccountNumber?address=${testConstants.defaultAddress}` + - `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}`, + `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}`, }); const expectedTransferResponse: ParentSubaccountTransferResponseObject = { @@ -574,26 +660,16 @@ describe('transfers-controller#V4', () => { expect(response.body.transfers).toEqual( expect.arrayContaining([ - expect.objectContaining({ - ...expectedTransferResponse, - }), - expect.objectContaining({ - ...expectedTransfer2Response, - }), - expect.objectContaining({ - ...expectedWithdrawalResponse, - }), - expect.objectContaining({ - ...expectedDepositResponse, - }), - expect.objectContaining({ - ...expectedTransferWithAlternateAddressResponse, - }), + expect.objectContaining(expectedTransferResponse), + expect.objectContaining(expectedTransfer2Response), + expect.objectContaining(expectedWithdrawalResponse), + expect.objectContaining(expectedDepositResponse), + expect.objectContaining(expectedTransferWithAlternateAddressResponse), ]), ); }); - it('Get /transfers/parentSubaccountNumber returns transfers/deposits/withdrawals and paginated', async () => { + it('Get /transfers/parentSubaccountNumber returns transfers with pagination', async () => { await testMocks.seedData(); const transfer2: TransferCreateObject = { senderSubaccountId: testConstants.defaultSubaccountId2, @@ -601,7 +677,7 @@ describe('transfers-controller#V4', () => { assetId: testConstants.defaultAsset2.id, size: '5', eventId: testConstants.defaultTendermintEventId2, - transactionHash: '', // TODO: Add a real transaction Hash + transactionHash: '', createdAt: testConstants.createdDateTime.toISO(), createdAtHeight: testConstants.createdHeight, }; @@ -616,121 +692,24 @@ describe('transfers-controller#V4', () => { const responsePage1: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/transfers/parentSubaccountNumber?address=${testConstants.defaultAddress}` + - `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}&page=1&limit=2`, + `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}&page=1&limit=2`, }); const responsePage2: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/transfers/parentSubaccountNumber?address=${testConstants.defaultAddress}` + - `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}&page=2&limit=2`, + `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}&page=2&limit=2`, }); - const expectedTransferResponse: ParentSubaccountTransferResponseObject = { - id: testConstants.defaultTransferId, - sender: { - address: testConstants.defaultAddress, - parentSubaccountNumber: testConstants.defaultSubaccount.subaccountNumber, - }, - recipient: { - address: testConstants.defaultAddress, - parentSubaccountNumber: testConstants.defaultSubaccount2.subaccountNumber, - }, - size: testConstants.defaultTransfer.size, - createdAt: testConstants.defaultTransfer.createdAt, - createdAtHeight: testConstants.defaultTransfer.createdAtHeight, - symbol: testConstants.defaultAsset.symbol, - type: TransferType.TRANSFER_OUT, - transactionHash: testConstants.defaultTransfer.transactionHash, - }; - - const expectedTransfer2Response: ParentSubaccountTransferResponseObject = { - id: TransferTable.uuid( - transfer2.eventId, - transfer2.assetId, - transfer2.senderSubaccountId, - transfer2.recipientSubaccountId, - transfer2.senderWalletAddress, - transfer2.recipientWalletAddress, - ), - sender: { - address: testConstants.defaultAddress, - parentSubaccountNumber: testConstants.defaultSubaccount2.subaccountNumber, - }, - recipient: { - address: testConstants.defaultAddress, - parentSubaccountNumber: testConstants.defaultSubaccount.subaccountNumber, - }, - size: transfer2.size, - createdAt: transfer2.createdAt, - createdAtHeight: transfer2.createdAtHeight, - symbol: testConstants.defaultAsset2.symbol, - type: TransferType.TRANSFER_IN, - transactionHash: transfer2.transactionHash, - }; - - const expectedDepositResponse: ParentSubaccountTransferResponseObject = { - id: testConstants.defaultDepositId, - sender: { - address: testConstants.defaultWalletAddress, - }, - recipient: { - address: testConstants.defaultAddress, - parentSubaccountNumber: testConstants.defaultSubaccount.subaccountNumber, - }, - size: testConstants.defaultDeposit.size, - createdAt: testConstants.defaultDeposit.createdAt, - createdAtHeight: testConstants.defaultDeposit.createdAtHeight, - symbol: testConstants.defaultAsset.symbol, - type: TransferType.DEPOSIT, - transactionHash: testConstants.defaultDeposit.transactionHash, - }; - - const expectedWithdrawalResponse: ParentSubaccountTransferResponseObject = { - id: testConstants.defaultWithdrawalId, - sender: { - address: testConstants.defaultAddress, - parentSubaccountNumber: testConstants.defaultSubaccount.subaccountNumber, - }, - recipient: { - address: testConstants.defaultWalletAddress, - }, - size: testConstants.defaultWithdrawal.size, - createdAt: testConstants.defaultWithdrawal.createdAt, - createdAtHeight: testConstants.defaultWithdrawal.createdAtHeight, - symbol: testConstants.defaultAsset.symbol, - type: TransferType.WITHDRAWAL, - transactionHash: testConstants.defaultWithdrawal.transactionHash, - }; - expect(responsePage1.body.pageSize).toStrictEqual(2); expect(responsePage1.body.offset).toStrictEqual(0); expect(responsePage1.body.totalResults).toStrictEqual(4); expect(responsePage1.body.transfers).toHaveLength(2); - expect(responsePage1.body.transfers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - ...expectedTransferResponse, - }), - expect.objectContaining({ - ...expectedTransfer2Response, - }), - ]), - ); expect(responsePage2.body.pageSize).toStrictEqual(2); expect(responsePage2.body.offset).toStrictEqual(2); expect(responsePage2.body.totalResults).toStrictEqual(4); expect(responsePage2.body.transfers).toHaveLength(2); - expect(responsePage2.body.transfers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - ...expectedWithdrawalResponse, - }), - expect.objectContaining({ - ...expectedDepositResponse, - }), - ]), - ); }); it('Get /transfers/parentSubaccountNumber excludes transfers for parent <> child subaccounts', async () => { @@ -741,7 +720,7 @@ describe('transfers-controller#V4', () => { assetId: testConstants.defaultAsset.id, size: '5', eventId: testConstants.defaultTendermintEventId2, - transactionHash: '', // TODO: Add a real transaction Hash + transactionHash: '', createdAt: testConstants.createdDateTime.toISO(), createdAtHeight: testConstants.createdHeight, }; @@ -751,7 +730,7 @@ describe('transfers-controller#V4', () => { assetId: testConstants.defaultAsset.id, size: '5', eventId: testConstants.defaultTendermintEventId3, - transactionHash: '', // TODO: Add a real transaction Hash + transactionHash: '', createdAt: testConstants.createdDateTime.toISO(), createdAtHeight: testConstants.createdHeight, }; @@ -765,7 +744,7 @@ describe('transfers-controller#V4', () => { const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/transfers/parentSubaccountNumber?address=${testConstants.defaultAddress}` + - `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}`, + `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}`, }); const expectedTransferResponse: ParentSubaccountTransferResponseObject = { @@ -789,182 +768,153 @@ describe('transfers-controller#V4', () => { expect(response.body.transfers.length).toEqual(1); expect(response.body.transfers).toEqual( expect.arrayContaining([ - expect.objectContaining({ - ...expectedTransferResponse, - }), + expect.objectContaining(expectedTransferResponse), ]), ); }); - it('Get /transfers/parentSubaccountNumber includes transfers for wallets/subaccounts(non parent) <> child subaccounts', async () => { + it('Get /transfers/parentSubaccountNumber includes deposits, withdrawals, and cross-parent transfers', async () => { await testMocks.seedData(); - const transferFromNonParent: TransferCreateObject = { + await WalletTable.create(defaultWallet); + + // Create 4 blocks for the transfers + const blocks: BlockCreateObject[] = []; + for (let i = 0; i < 4; i++) { + blocks.push({ + blockHeight: (3 + i).toString(), + time: testConstants.createdDateTime.plus({ minutes: i }).toISO(), + }); + } + await Promise.all(blocks.map((b) => BlockTable.create(b))); + + // Create 4 TendermintEvents + const events: TendermintEventCreateObject[] = []; + for (let i = 0; i < 4; i++) { + events.push({ + blockHeight: (3 + i).toString(), + transactionIndex: 0, + eventIndex: 0, + }); + } + await Promise.all(events.map((e) => TendermintEventTable.create(e))); + + const eventIds = events.map((e) => TendermintEventTable.createEventId( + e.blockHeight, e.transactionIndex, e.eventIndex), + ); + + // Transfer from different parent (parent 1) to parent 0 child subaccount + const transferFromDifferentParent: TransferCreateObject = { senderSubaccountId: testConstants.defaultSubaccountId2, recipientSubaccountId: testConstants.isolatedSubaccountId, assetId: testConstants.defaultAsset.id, size: '5', - eventId: testConstants.defaultTendermintEventId2, - transactionHash: '', // TODO: Add a real transaction Hash + eventId: eventIds[0], + transactionHash: 'transfer_from_different_parent', createdAt: testConstants.createdDateTime.toISO(), createdAtHeight: testConstants.createdHeight, }; - const transferToNonParent: TransferCreateObject = { + + // Transfer from parent 0 child subaccount to different parent (parent 1) + const transferToDifferentParent: TransferCreateObject = { senderSubaccountId: testConstants.isolatedSubaccountId2, recipientSubaccountId: testConstants.defaultSubaccountId2, assetId: testConstants.defaultAsset.id, - size: '5', - eventId: testConstants.defaultTendermintEventId3, - transactionHash: '', // TODO: Add a real transaction Hash - createdAt: testConstants.createdDateTime.toISO(), + size: '7', + eventId: eventIds[1], + transactionHash: 'transfer_to_different_parent', + createdAt: testConstants.createdDateTime.plus({ minutes: 1 }).toISO(), createdAtHeight: testConstants.createdHeight, }; - const depositToChildSA: TransferCreateObject = { - senderWalletAddress: defaultWalletAddress, - recipientSubaccountId: isolatedSubaccountId, - assetId: defaultAsset.id, + + // Deposit from wallet to parent 0 child subaccount + const deposit: TransferCreateObject = { + senderWalletAddress: testConstants.defaultWalletAddress, + recipientSubaccountId: testConstants.isolatedSubaccountId, + assetId: testConstants.defaultAsset.id, size: '10', - eventId: defaultTendermintEventId4, - transactionHash: '', // TODO: Add a real transaction Hash - createdAt: createdDateTime.toISO(), - createdAtHeight: createdHeight, + eventId: eventIds[2], + transactionHash: 'deposit', + createdAt: testConstants.createdDateTime.plus({ minutes: 2 }).toISO(), + createdAtHeight: testConstants.createdHeight, }; - const withdrawFromChildSA: TransferCreateObject = { - senderSubaccountId: isolatedSubaccountId, - recipientWalletAddress: defaultWalletAddress, - assetId: defaultAsset.id, - size: '10', - eventId: defaultTendermintEventId4, - transactionHash: '', // TODO: Add a real transaction Hash - createdAt: createdDateTime.toISO(), - createdAtHeight: createdHeight, + + // Withdrawal from parent 0 child subaccount to wallet + const withdrawal: TransferCreateObject = { + senderSubaccountId: testConstants.isolatedSubaccountId, + recipientWalletAddress: testConstants.defaultWalletAddress, + assetId: testConstants.defaultAsset.id, + size: '12', + eventId: eventIds[3], + transactionHash: 'withdrawal', + createdAt: testConstants.createdDateTime.plus({ minutes: 3 }).toISO(), + createdAtHeight: testConstants.createdHeight, }; - await WalletTable.create(defaultWallet); + await Promise.all([ - TransferTable.create(transferFromNonParent), - TransferTable.create(transferToNonParent), - TransferTable.create(depositToChildSA), - TransferTable.create(withdrawFromChildSA), + TransferTable.create(transferFromDifferentParent), + TransferTable.create(transferToDifferentParent), + TransferTable.create(deposit), + TransferTable.create(withdrawal), ]); - const parentSubaccountNumber: number = 0; const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/transfers/parentSubaccountNumber?address=${testConstants.defaultAddress}` + - `&parentSubaccountNumber=${parentSubaccountNumber}`, + '&parentSubaccountNumber=0', }); - const expectedTransferResponse1: ParentSubaccountTransferResponseObject = { - id: TransferTable.uuid( - transferFromNonParent.eventId, - transferFromNonParent.assetId, - transferFromNonParent.senderSubaccountId, - transferFromNonParent.recipientSubaccountId, - transferFromNonParent.senderWalletAddress, - transferFromNonParent.recipientWalletAddress, - ), - sender: { - address: testConstants.defaultAddress, + expect(response.body.transfers.length).toEqual(4); + + // Verify each transfer type is present + const transfers = response.body.transfers; + + // Check transfer from different parent + expect(transfers).toContainEqual(expect.objectContaining({ + sender: expect.objectContaining({ parentSubaccountNumber: testConstants.defaultSubaccount2.subaccountNumber, - }, - recipient: { - address: testConstants.defaultAddress, + }), + recipient: expect.objectContaining({ parentSubaccountNumber: 0, - }, - size: transferFromNonParent.size, - createdAt: transferFromNonParent.createdAt, - createdAtHeight: transferFromNonParent.createdAtHeight, - symbol: testConstants.defaultAsset.symbol, + }), + size: '5', type: TransferType.TRANSFER_IN, - transactionHash: transferFromNonParent.transactionHash, - }; - const expectedTransferResponse2: ParentSubaccountTransferResponseObject = { - id: TransferTable.uuid( - transferToNonParent.eventId, - transferToNonParent.assetId, - transferToNonParent.senderSubaccountId, - transferToNonParent.recipientSubaccountId, - transferToNonParent.senderWalletAddress, - transferToNonParent.recipientWalletAddress, - ), - sender: { - address: testConstants.defaultAddress, + })); + + // Check transfer to different parent + expect(transfers).toContainEqual(expect.objectContaining({ + sender: expect.objectContaining({ parentSubaccountNumber: 0, - }, - recipient: { - address: testConstants.defaultAddress, + }), + recipient: expect.objectContaining({ parentSubaccountNumber: testConstants.defaultSubaccount2.subaccountNumber, - }, - size: transferToNonParent.size, - createdAt: transferToNonParent.createdAt, - createdAtHeight: transferToNonParent.createdAtHeight, - symbol: testConstants.defaultAsset.symbol, + }), + size: '7', type: TransferType.TRANSFER_OUT, - transactionHash: transferToNonParent.transactionHash, - }; - const expectedDepositResponse: ParentSubaccountTransferResponseObject = { - id: TransferTable.uuid( - depositToChildSA.eventId, - depositToChildSA.assetId, - depositToChildSA.senderSubaccountId, - depositToChildSA.recipientSubaccountId, - depositToChildSA.senderWalletAddress, - depositToChildSA.recipientWalletAddress, - ), + })); + + // Check deposit + expect(transfers).toContainEqual(expect.objectContaining({ sender: { address: testConstants.defaultWalletAddress, }, - recipient: { - address: testConstants.defaultAddress, + recipient: expect.objectContaining({ parentSubaccountNumber: 0, - }, - size: depositToChildSA.size, - createdAt: depositToChildSA.createdAt, - createdAtHeight: depositToChildSA.createdAtHeight, - symbol: testConstants.defaultAsset.symbol, + }), + size: '10', type: TransferType.DEPOSIT, - transactionHash: depositToChildSA.transactionHash, - }; - const expectedWithdrawalResponse: ParentSubaccountTransferResponseObject = { - id: TransferTable.uuid( - withdrawFromChildSA.eventId, - withdrawFromChildSA.assetId, - withdrawFromChildSA.senderSubaccountId, - withdrawFromChildSA.recipientSubaccountId, - withdrawFromChildSA.senderWalletAddress, - withdrawFromChildSA.recipientWalletAddress, - ), - sender: { - address: testConstants.defaultAddress, + })); + + // Check withdrawal + expect(transfers).toContainEqual(expect.objectContaining({ + sender: expect.objectContaining({ parentSubaccountNumber: 0, - }, + }), recipient: { address: testConstants.defaultWalletAddress, }, - size: withdrawFromChildSA.size, - createdAt: withdrawFromChildSA.createdAt, - createdAtHeight: withdrawFromChildSA.createdAtHeight, - symbol: testConstants.defaultAsset.symbol, + size: '12', type: TransferType.WITHDRAWAL, - transactionHash: withdrawFromChildSA.transactionHash, - }; - - expect(response.body.transfers.length).toEqual(4); - expect(response.body.transfers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - ...expectedTransferResponse1, - }), - expect.objectContaining({ - ...expectedTransferResponse2, - }), - expect.objectContaining({ - ...expectedDepositResponse, - }), - expect.objectContaining({ - ...expectedWithdrawalResponse, - }), - ]), - ); + })); }); it('Get /transfers/parentSubaccountNumber returns empty when there are no transfers', async () => { @@ -973,23 +923,23 @@ describe('transfers-controller#V4', () => { const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/transfers/parentSubaccountNumber?address=${testConstants.defaultAddress}` + - `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}`, + `&parentSubaccountNumber=${testConstants.defaultSubaccount.subaccountNumber}`, }); expect(response.body.transfers).toHaveLength(0); }); - it('Get /transfers/parentSubaccountNumber with non-existent address and subaccount number returns 404', async () => { + it('Get /transfers/parentSubaccountNumber with non-existent address returns 404', async () => { const response: request.Response = await sendRequest({ type: RequestMethod.GET, - path: '/v4/transfers/parentSubaccountNumber?address=invalidaddress&parentSubaccountNumber=100', + path: '/v4/transfers/parentSubaccountNumber?address=invalidaddress&parentSubaccountNumber=0', expectedStatus: 404, }); expect(response.body).toEqual({ errors: [ { - msg: 'No subaccount found with address invalidaddress and parentSubaccountNumber 100', + msg: 'No subaccount found with address invalidaddress and parentSubaccountNumber 0', }, ], }); diff --git a/indexer/services/comlink/src/controllers/api/v4/transfers-controller.ts b/indexer/services/comlink/src/controllers/api/v4/transfers-controller.ts index bc826150959..3230f61e806 100644 --- a/indexer/services/comlink/src/controllers/api/v4/transfers-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/transfers-controller.ts @@ -170,15 +170,13 @@ class TransfersController extends Controller { ), TransferTable.findAllToOrFromParentSubaccount( { - [QueryableField.SUBACCOUNT_ID]: subaccountIds, - [QueryableField.ADDRESS]: address, - [QueryableField.PARENT_SUBACCOUNT_NUMBER]: parentSubaccountNumber, - [QueryableField.LIMIT]: limit, - [QueryableField.CREATED_BEFORE_OR_AT_HEIGHT]: createdBeforeOrAtHeight + subaccountId: subaccountIds, + limit, + createdBeforeOrAtHeight: createdBeforeOrAtHeight ? createdBeforeOrAtHeight.toString() : undefined, - [QueryableField.CREATED_BEFORE_OR_AT]: createdBeforeOrAt, - [QueryableField.PAGE]: page, + createdBeforeOrAt, + page, }, [QueryableField.LIMIT], { @@ -227,7 +225,6 @@ class TransfersController extends Controller { parentSubaccountNumber); }); - // No filtering needed - it's done at the SQL level now return { transfers: transfersResponse, pageSize, From 33ba5b41535cdfc7825554316345d5f2d851e28c Mon Sep 17 00:00:00 2001 From: David Li Date: Wed, 5 Nov 2025 15:43:16 -0500 Subject: [PATCH 4/5] Done testing in staging --- .github/workflows/indexer-build-and-push-dev-staging.yml | 1 - .github/workflows/protocol-build-and-push-snapshot.yml | 1 - .github/workflows/protocol-build-and-push.yml | 1 - indexer/packages/postgres/src/stores/transfer-table.ts | 2 +- 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/indexer-build-and-push-dev-staging.yml b/.github/workflows/indexer-build-and-push-dev-staging.yml index d2ac3acf85c..87b597c0a62 100644 --- a/.github/workflows/indexer-build-and-push-dev-staging.yml +++ b/.github/workflows/indexer-build-and-push-dev-staging.yml @@ -6,7 +6,6 @@ on: # yamllint disable-line rule:truthy - main - 'release/indexer/v[0-9]+.[0-9]+.x' # e.g. release/indexer/v0.1.x - 'release/indexer/v[0-9]+.x' # e.g. release/indexer/v1.x - - davidli/transfers_endpoint # TODO(DEC-837): Customize github build and push to ECR by service with paths jobs: diff --git a/.github/workflows/protocol-build-and-push-snapshot.yml b/.github/workflows/protocol-build-and-push-snapshot.yml index 3c2ebd71417..ae94a69a836 100644 --- a/.github/workflows/protocol-build-and-push-snapshot.yml +++ b/.github/workflows/protocol-build-and-push-snapshot.yml @@ -6,7 +6,6 @@ on: # yamllint disable-line rule:truthy - main - 'release/protocol/v[0-9]+.[0-9]+.x' # e.g. release/protocol/v0.1.x - 'release/protocol/v[0-9]+.x' # e.g. release/protocol/v1.x - - davidli/transfers_endpoint jobs: build-and-push-snapshot-dev: diff --git a/.github/workflows/protocol-build-and-push.yml b/.github/workflows/protocol-build-and-push.yml index ec64fe1df77..3e9adfcf809 100644 --- a/.github/workflows/protocol-build-and-push.yml +++ b/.github/workflows/protocol-build-and-push.yml @@ -6,7 +6,6 @@ on: # yamllint disable-line rule:truthy - main - 'release/protocol/v[0-9]+.[0-9]+.x' # e.g. release/protocol/v0.1.x - 'release/protocol/v[0-9]+.x' # e.g. release/protocol/v1.x - - davidli/transfers_endpoint jobs: build-and-push-dev: diff --git a/indexer/packages/postgres/src/stores/transfer-table.ts b/indexer/packages/postgres/src/stores/transfer-table.ts index 0cae6792efc..e2b20d01d56 100644 --- a/indexer/packages/postgres/src/stores/transfer-table.ts +++ b/indexer/packages/postgres/src/stores/transfer-table.ts @@ -431,7 +431,7 @@ export async function findAllToOrFromParentSubaccount( const count: { count?: string } = await baseQuery .clone() - .clearSelect() // Add this + .clearSelect() .clearOrder() .count('transfers.id as count') .first() as unknown as { count?: string }; From 1e63100a77455af68b1f8da10127c40f4c47e031 Mon Sep 17 00:00:00 2001 From: David Li Date: Wed, 5 Nov 2025 15:46:12 -0500 Subject: [PATCH 5/5] Remove unused definition --- indexer/packages/postgres/src/types/query-types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/indexer/packages/postgres/src/types/query-types.ts b/indexer/packages/postgres/src/types/query-types.ts index 0a6677eb828..f67918301b5 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -105,7 +105,6 @@ export enum QueryableField { SVM_ADDRESS = 'svm_address', EVM_ADDRESS = 'evm_address', DYDX_ADDRESS = 'dydx_address', - PARENT_SUBACCOUNT_NUMBER = 'parentSubaccountNumber', } export interface QueryConfig {