diff --git a/CHANGELOG.md b/CHANGELOG.md index 1031ce2cc..2ef2d95d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [0.40.1](https://github.com/aragon/app-backend/compare/v0.40.0...v0.40.1) (2026-10-07) + +### Bug Fixes + +* **members:** count delegators from the index only ([5062eb6](https://github.com/aragon/app-backend/commit/5062eb637ea853b71516084ece0a0782c494d44a)) +* **transactions:** check spam once per token before paging ([ccc66dd](https://github.com/aragon/app-backend/commit/ccc66ddf89a164401afe49d888faa601daeb287f)) ## [0.40.0](https://github.com/aragon/app-backend/compare/v0.39.0...v0.40.0) (2026-10-05) ### Features diff --git a/package.json b/package.json index 804bb3a71..9e3d3e276 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aragon-backend", "description": "OpenSource backend services", - "version": "0.40.0", + "version": "0.40.1", "main": "index.ts", "license": "AGPL-3.0", "author": "Aragon Development Team", diff --git a/src/models/schema/logDelegateChanged.ts b/src/models/schema/logDelegateChanged.ts index 449720e10..25804253f 100644 --- a/src/models/schema/logDelegateChanged.ts +++ b/src/models/schema/logDelegateChanged.ts @@ -111,37 +111,24 @@ export default class LogDelegateChanged extends Model { ): Promise> { if (memberAddresses.length === 0) return {} - const results = await this.aggregate([ - { - $match: { - network, - tokenAddress, - $or: [{ toDelegate: { $in: memberAddresses } }, { fromDelegate: { $in: memberAddresses } }], - }, - }, - { - $project: { - deltas: { - $concatArrays: [ - { - $cond: [{ $in: ['$toDelegate', memberAddresses] }, [{ delegate: '$toDelegate', delta: 1 }], []], - }, - { - $cond: [{ $in: ['$fromDelegate', memberAddresses] }, [{ delegate: '$fromDelegate', delta: -1 }], []], - }, - ], - }, - }, - }, - { $unwind: '$deltas' }, - { $group: { _id: '$deltas.delegate', count: { $sum: '$deltas.delta' } } }, - { $match: { count: { $gt: 0 } } }, - ]).allowDiskUse(true) - - return results.reduce((acc: Record, item: { _id: string; count: number }) => { - acc[item._id] = item.count - return acc - }, {}) + const [incoming, outgoing] = await Promise.all( + (['toDelegate', 'fromDelegate'] as const).map(field => + this.aggregate<{ _id: string; count: number }>([ + { $match: { network, tokenAddress, [field]: { $in: memberAddresses } } }, + { $group: { _id: `$${field}`, count: { $sum: 1 } } }, + ]).allowDiskUse(true), + ), + ) + + const counts: Record = {} + for (const { _id, count } of incoming) counts[_id] = count + for (const { _id, count } of outgoing) counts[_id] = (counts[_id] ?? 0) - count + + const result: Record = {} + for (const [member, count] of Object.entries(counts)) { + if (count > 0) result[member] = count + } + return result } static async findDelegatorsForMember( diff --git a/src/models/schema/transaction.ts b/src/models/schema/transaction.ts index 7be3f1868..e24f548db 100644 --- a/src/models/schema/transaction.ts +++ b/src/models/schema/transaction.ts @@ -348,74 +348,65 @@ export default class Transaction extends Model { filter.daoAddress = { $in: extraParams.daoAddresses } } - const spamFilterStages = - extraParams.includeSpam === true - ? [] - : [ - { - $lookup: { - from: ICollectionNames.Token, - localField: 'token.address', - foreignField: 'address', - let: { txNetwork: '$network' }, - pipeline: [ - { $match: { $expr: { $eq: ['$network', '$$txNetwork'] } } }, - { $project: { _id: 0, isSpam: 1 } }, - { $limit: 1 }, - ], - as: '_tokenRef', - }, - }, - { $match: { '_tokenRef.isSpam': { $ne: true } } }, - { $project: { _tokenRef: 0 } }, - ] - const currentPage = request.skip / request.limit + 1 - const dataPipeline: any[] = [ - { $match: filter }, + const pageStages = [ { $sort: request.sort }, { $project: { rawActions: 0, actions: 0 } }, - ...spamFilterStages, { $skip: request.skip }, { $limit: request.limit }, ] - const countPipeline: any[] = - extraParams.includeSpam === true - ? [{ $match: filter }, { $count: 'total' }] - : [ - { $match: filter }, - { - $group: { - _id: { network: '$network', address: '$token.address' }, - records: { $sum: 1 }, - }, - }, - { - $lookup: { - from: ICollectionNames.Token, - localField: '_id.address', - foreignField: 'address', - let: { txNetwork: '$_id.network' }, - pipeline: [ - { $match: { $expr: { $eq: ['$network', '$$txNetwork'] } } }, - { $project: { _id: 0, isSpam: 1 } }, - { $limit: 1 }, - ], - as: '_tokenRef', - }, - }, - { $match: { '_tokenRef.isSpam': { $ne: true } } }, - { $group: { _id: null, total: { $sum: '$records' } } }, - ] - - const [rawData, countResult] = await Promise.all([ - this.aggregate(dataPipeline).allowDiskUse(true), - this.aggregate(countPipeline).allowDiskUse(true), - ]) - - const totalRecords = countResult.length > 0 ? countResult[0].total : 0 + let totalRecords = 0 + let rawData: any[] = [] + + if (extraParams.includeSpam === true) { + const [countResult, rows] = await Promise.all([ + this.aggregate([{ $match: filter }, { $count: 'total' }]).allowDiskUse(true), + this.aggregate([{ $match: filter }, ...pageStages]).allowDiskUse(true), + ]) + totalRecords = countResult.length > 0 ? countResult[0].total : 0 + rawData = rows + } else { + const [summary] = await this.aggregate([ + { $match: filter }, + { $group: { _id: { network: '$network', address: '$token.address' }, records: { $sum: 1 } } }, + { + $lookup: { + from: ICollectionNames.Token, + localField: '_id.address', + foreignField: 'address', + let: { txNetwork: '$_id.network' }, + pipeline: [ + { $match: { $expr: { $eq: ['$network', '$$txNetwork'] } } }, + { $project: { _id: 0, isSpam: 1 } }, + { $limit: 1 }, + ], + as: '_tokenRef', + }, + }, + { $set: { isSpam: { $in: [true, '$_tokenRef.isSpam'] } } }, + { + $group: { + _id: null, + totalRecords: { $sum: { $cond: ['$isSpam', 0, '$records'] } }, + spamTokens: { $push: { $cond: ['$isSpam', '$_id', '$$REMOVE'] } }, + }, + }, + ]).allowDiskUse(true) + + const spamTokens: { network: string; address?: string }[] = summary?.spamTokens ?? [] + totalRecords = summary?.totalRecords ?? 0 + + let pageFilter: any = filter + if (spamTokens.length > 0) { + const spamPairs = spamTokens.map(token => ({ network: token.network, 'token.address': token.address ?? null })) + pageFilter = { ...filter, $nor: spamPairs } + } + + rawData = await this.aggregate([{ $match: pageFilter }, ...pageStages]).allowDiskUse(true) + } + const totalPages = Math.ceil(totalRecords / request.limit) if (currentPage > totalPages) { diff --git a/test/unit/models/schema/logDelegateChanged.spec.ts b/test/unit/models/schema/logDelegateChanged.spec.ts index 75404d8b0..62b4107f4 100644 --- a/test/unit/models/schema/logDelegateChanged.spec.ts +++ b/test/unit/models/schema/logDelegateChanged.spec.ts @@ -241,6 +241,77 @@ describe('Model: LogDelegateChanged', () => { const result = await Models.LogDelegateChanged.countActiveDelegationsForMembers(TOKEN_ADDRESS, NETWORK, []) expect(result).to.deep.equal({}) }) + + describe('when counting both sides of a delegation', () => { + const delegate = (fromDelegate: string, toDelegate: string, transactionHash: string, extra = {}) => + Models.LogDelegateChanged.create({ + network: NETWORK, + tokenAddress: TOKEN_ADDRESS, + delegator: ALICE, + fromDelegate, + toDelegate, + blockNumber: 50, + blockTimestamp: 500, + transactionHash, + transactionIndex: 0, + logIndex: 0, + ...extra, + }) + + it('moves the delegator from the old member to the new one', async () => { + await delegate(ZERO, BOB, '0xboth1') + await delegate(BOB, JORDAN, '0xboth2') + + const result = await Models.LogDelegateChanged.countActiveDelegationsForMembers(TOKEN_ADDRESS, NETWORK, [ + BOB, + JORDAN, + ]) + + expect(result).to.deep.equal({ [JORDAN]: 1 }) + }) + + it('keeps the count when a log delegates from and to the same member', async () => { + await delegate(ZERO, BOB, '0xself1') + await delegate(BOB, BOB, '0xself2') + + const result = await Models.LogDelegateChanged.countActiveDelegationsForMembers(TOKEN_ADDRESS, NETWORK, [BOB]) + + expect(result).to.deep.equal({ [BOB]: 1 }) + }) + + it('leaves out a member with no logs and a member with only outgoing logs', async () => { + await delegate(BOB, JORDAN, '0xout1') + + const result = await Models.LogDelegateChanged.countActiveDelegationsForMembers(TOKEN_ADDRESS, NETWORK, [ + ALICE, + BOB, + JORDAN, + ]) + + expect(result).to.deep.equal({ [JORDAN]: 1 }) + }) + + it('counts once when the same member is asked twice', async () => { + await delegate(ZERO, BOB, '0xdup1') + + const result = await Models.LogDelegateChanged.countActiveDelegationsForMembers(TOKEN_ADDRESS, NETWORK, [ + BOB, + BOB, + ]) + + expect(result).to.deep.equal({ [BOB]: 1 }) + }) + + it('ignores logs from another token or network', async () => { + await delegate(ZERO, BOB, '0xscope1') + await delegate(ZERO, BOB, '0xscope2', { tokenAddress: ZERO }) + await delegate(BOB, JORDAN, '0xscope3', { network: NetworksEnum.polygonMainnet }) + + const result = await Models.LogDelegateChanged.countActiveDelegationsForMembers(TOKEN_ADDRESS, NETWORK, [BOB]) + + expect(result).to.deep.equal({ [BOB]: 1 }) + }) + }) }) describe('findDelegatorsForMember', () => { diff --git a/test/unit/models/schema/transaction.spec.ts b/test/unit/models/schema/transaction.spec.ts index 1e90ee6fe..9f5d2a7fb 100644 --- a/test/unit/models/schema/transaction.spec.ts +++ b/test/unit/models/schema/transaction.spec.ts @@ -735,6 +735,151 @@ describe('Model: Transaction', () => { expect(tokenAddresses).to.include(spamTokenAddress) expect(tokenAddresses).to.include(legitTokenAddress) }) + + describe('when spam rows are skipped before the page', () => { + const newestFirst = { sort: 'blockNumber', order: 'desc' as const } + + const createTokenTx = (transactionHash: string, blockNumber: number, tokenAddress: string, extra = {}) => + Models.Transaction.create({ + transactionHash, + blockNumber, + network, + side: ITransactionSide.deposit, + type: ITransactionType.erc20, + fromAddress: '0xfrom9', + toAddress: daoAddress, + value: '1', + tokenAddress, + daoAddress, + token: { network, type: ITokenType.ERC20, address: tokenAddress, name: 'T', symbol: 'T', decimals: 18 }, + ...extra, + }) + + it('returns the right rows in order on a later page', async () => { + await createTokenTx('0xspamTx3', 103, spamTokenAddress) + await createTokenTx('0xlegitTx3', 104, legitTokenAddress) + await createTokenTx('0xspamTx4', 105, spamTokenAddress) + await createTokenTx('0xlegitTx4', 106, legitTokenAddress) + + const { data, metadata } = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress, network }, + paginationParams: { ...newestFirst, pageSize: 2, page: 2 }, + }) + + expect(metadata).to.deep.include({ page: 2, totalRecords: 4, totalPages: 2 }) + expect(data.map((tx: any) => tx.transactionHash)).to.deep.eq(['0xnativeTx1', '0xlegitTx1']) + }) + + it('drops a token only on the network where it is spam', async () => { + const otherNetwork = NetworksEnum.polygonMainnet + const otherDao = '0xdaoOtherNet4444444444444444444444444444444' + await Models.Token.create({ + address: spamTokenAddress, + network: otherNetwork, + type: ITokenType.ERC20, + name: 'Same Address', + symbol: 'SAME', + isSpam: false, + }) + await createTokenTx('0xotherNetTx', 200, spamTokenAddress, { + network: otherNetwork, + daoAddress: otherDao, + token: { network: otherNetwork, type: ITokenType.ERC20, address: spamTokenAddress, decimals: 18 }, + }) + + const { data, metadata } = await Models.Transaction.findWithPagination({ + extraParams: { daoAddresses: [daoAddress, otherDao] }, + paginationParams: newestFirst, + }) + + expect(metadata.totalRecords).to.eq(3) + expect(data.map((tx: any) => tx.transactionHash)).to.deep.eq(['0xotherNetTx', '0xnativeTx1', '0xlegitTx1']) + }) + + it('returns nothing when filtering by a spam token, and its rows for a normal token', async () => { + const spam = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress, network, tokenAddress: spamTokenAddress }, + paginationParams: {}, + }) + const legit = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress, network, tokenAddress: legitTokenAddress }, + paginationParams: {}, + }) + + expect(spam).to.deep.eq(ModelUtils.paginateEmptyResponse(10)) + expect(legit.metadata.totalRecords).to.eq(1) + expect(legit.data.map((tx: any) => tx.transactionHash)).to.deep.eq(['0xlegitTx1']) + }) + + it('keeps rows of a token that has no Token row', async () => { + await createTokenTx('0xunknownTx', 107, '0xunknownToken555555555555555555555555555') + + const { data, metadata } = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress, network }, + paginationParams: newestFirst, + }) + + expect(metadata.totalRecords).to.eq(3) + expect(data.map((tx: any) => tx.transactionHash)).to.include('0xunknownTx') + }) + + it('returns the empty response for a page past the end', async () => { + const result = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress, network }, + paginationParams: { pageSize: 2, page: 5 }, + }) + + expect(result).to.deep.eq(ModelUtils.paginateEmptyResponse(2)) + }) + + it('returns the empty response when every row is spam', async () => { + const spamOnlyDao = '0xdaoSpamOnly66666666666666666666666666666' + await createTokenTx('0xspamOnlyTx', 300, spamTokenAddress, { daoAddress: spamOnlyDao }) + + const result = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress: spamOnlyDao, network }, + paginationParams: {}, + }) + + expect(result).to.deep.eq(ModelUtils.paginateEmptyResponse(10)) + }) + + it('keeps the same order across pages when rows share a block number', async () => { + await createTokenTx('0xtieA', 110, legitTokenAddress) + await createTokenTx('0xtieSpam', 110, spamTokenAddress) + await createTokenTx('0xtieB', 110, legitTokenAddress) + await createTokenTx('0xtieC', 110, legitTokenAddress) + + const allAtOnce = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress, network }, + paginationParams: { ...newestFirst, pageSize: 10 }, + }) + + const onePerPage: string[] = [] + for (let page = 1; page <= allAtOnce.metadata.totalRecords; page++) { + const { data } = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress, network }, + paginationParams: { ...newestFirst, pageSize: 1, page }, + }) + onePerPage.push(data[0].transactionHash) + } + + expect(onePerPage).to.deep.eq(allAtOnce.data.map((tx: any) => tx.transactionHash)) + expect(onePerPage).to.not.include('0xtieSpam') + }) + + it('still counts and pages spam rows when includeSpam is true', async () => { + await createTokenTx('0xspamTx5', 103, spamTokenAddress) + + const { data, metadata } = await Models.Transaction.findWithPagination({ + extraParams: { daoAddress, network, includeSpam: true }, + paginationParams: { ...newestFirst, pageSize: 2, page: 1 }, + }) + + expect(metadata).to.deep.include({ page: 1, totalRecords: 4, totalPages: 2 }) + expect(data.map((tx: any) => tx.transactionHash)).to.deep.eq(['0xspamTx5', '0xnativeTx1']) + }) + }) }) describe('executions', () => {