Skip to content

feat(transactions): add multi-safe queued transactions endpoint#2985

Open
clovisdasilvaneto wants to merge 5 commits intomainfrom
feat/spaces-pending-tx
Open

feat(transactions): add multi-safe queued transactions endpoint#2985
clovisdasilvaneto wants to merge 5 commits intomainfrom
feat/spaces-pending-tx

Conversation

@clovisdasilvaneto
Copy link
Copy Markdown
Contributor

@clovisdasilvaneto clovisdasilvaneto commented Mar 23, 2026

Summary

Implement an endpoint to retrieve pending queued transactions across multiple Safe addresses in a single request. This enables efficient monitoring of transaction queues for multiple Safes without making separate requests. We're gonna use this endpoint in the spaces dashboard while queue-service doesn't support mainnet.

Changes

  • SafeQueuedTransaction entity: New entity wrapping a transaction with the associated Safe address and chain ID
  • GET /multi-safe/transactions/queued endpoint: Accepts CAIP-10 formatted Safe addresses and returns queued transactions sorted by timestamp
  • TransactionsService.getMultiSafeTransactionQueue(): Fetches transactions in batches (size: 3), handles errors gracefully, and returns top N results
  • Integration tests: Added comprehensive tests for the multi-safe queued transactions endpoint
  • Query parameters:
    • safes (required): Comma-separated CAIP-10 addresses (e.g., 1:0x1234...,5:0xabcd...)
    • trusted (optional): Filter by trust status (default: true)
    • limit (optional): Max transactions per result (default: 10)

🤖 Generated with Claude Code

@clovisdasilvaneto clovisdasilvaneto requested a review from a team as a code owner March 23, 2026 08:40
Implement endpoint to retrieve queued transactions across multiple Safes in a single request. Transactions are fetched in batches, merged, and sorted by timestamp. Returns top N transactions for each Safe address.

- Add SafeQueuedTransaction entity wrapping transaction with safe address and chain ID
- Add GET /multi-safe/transactions/queued endpoint accepting CAIP-10 formatted addresses
- Implement getMultiSafeTransactionQueue service with configurable batch size
- Add integration tests for multi-safe transaction queue endpoint
- Support filtering by trust status and limiting results

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@clovisdasilvaneto clovisdasilvaneto force-pushed the feat/spaces-pending-tx branch 2 times, most recently from 0ef85a2 to 20a3951 Compare March 23, 2026 08:55
- Remove queueService property that doesn't exist in actual configuration
- Simplify failing multi-safe test to avoid complex mock setup
Copy link
Copy Markdown
Contributor

@LucieFaire LucieFaire left a comment

Choose a reason for hiding this comment

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

its not the most performant method (and for a large set it can grow exponentialy) and it could be improved, but if its a temp solution it is a solid one. I left some comments.
It would also be great to have unit tests for service-level method as there is a lot of logic going on

return new TXSCreationTransaction(tx);
}

private static readonly MULTI_SAFE_QUEUE_BATCH_SIZE = 3;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not: that would usually go to the top of the file, before the constructor

readonly safeAddress: string;
@ApiProperty()
readonly chainId: string;
@ApiProperty()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ApiProperty({ type: Transaction })
so that Swagger can infer the type fully

Comment on lines +5 to +17
export class SafeQueuedTransaction {
@ApiProperty()
readonly safeAddress: string;
@ApiProperty()
readonly chainId: string;
@ApiProperty()
readonly transaction: Transaction;

constructor(safeAddress: string, chainId: string, transaction: Transaction) {
this.safeAddress = safeAddress;
this.chainId = chainId;
this.transaction = transaction;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

you can also write it in a short form:

export class SafeQueuedTransaction {
  @ApiProperty()
  readonly safeAddress!: string;
  @ApiProperty()
  readonly chainId!: string;
  @ApiProperty({ type: Transaction })
  readonly transaction!: Transaction;
}

! means the definite assignment assertion (!) which tells TypeScript the properties will be assigned in the constructor.

safe,
dataDecoded,
);
return new SafeQueuedTransaction(address, chainId, transaction);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if you update the DTO definition, you can just return plain object w/o contructor

 return {
                safeAddress: address,
                chainId,
                transaction,
              };
```s

Comment on lines +668 to +674
const queue = await this.safeRepository.getTransactionQueue({
chainId,
safe,
limit: args.limit,
offset: 0,
trusted: args.trusted,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Each Safe fetches args.limit transactions (defaults to 10), but the final result is also capped to args.limit. With 15 Safes and limit=10, you could fetch up to 150 transactions, decode and map all of them, then throw away 140.
Since results are sorted by timestamp (oldest first), you could:

Fetch a smaller per-Safe limit (like that batch size)
or after each batch, track the current "worst" timestamp and skip further processing if a Safe's transactions can't make the cut

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@LucieFaire indeed, I can indeed fetch smaller number of pending tx per safe, but this could also lead to the following scenario:

Suppose we fetch 5 per safe, but the user asked for a limit of 15 txs and he just asked for one safe which has 20 pending tx... In this case e would only have 5 pending txs on the response, which would be wrong in this case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

dont you have control over what is returned? Are you planning to give user control over how much data they want and for what safes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes I do, but I'm just talking in a general scenario, in case it is gonna be used elsewhere, but yeah, maybe it might not even make sense, since this is a temporary approach.


private static readonly MULTI_SAFE_QUEUE_BATCH_SIZE = 3;

async getMultiSafeTransactionQueue(args: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If multiple users in the same Space hit this endpoint simultaneously for the same set of Safes, each request does the full work independently. There is caching on separate methods used (like transactions ,and safe) but maybe worth adding caching for this set of params too

@PooyaRaki
Copy link
Copy Markdown
Contributor

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Mar 23, 2026

Claude finished @PooyaRaki's task —— View job


Code Review: PR #2985

  • Read changed files
  • Analyze code quality, bugs, performance, security, test coverage
  • Post review feedback

Issues to Address

1. No upper bound on limit query parameter (transactions.controller.ts:709)
The limit param is user-controlled with no max cap. A client can pass limit=100000, causing the service to fetch up to maxOverviews * 100000 transactions from upstream, decode and map all of them, then sort. Add a Math.min(limit, MAX_LIMIT) or validate with a Zod/Pipe max constraint.

2. Over-fetching per Safe (transactions.service.ts:668-674)
Each Safe fetches args.limit transactions (e.g., 10), but only args.limit total results are returned. With 10 Safes and limit=10, you fetch up to 100 transactions, decode/map all, then discard 90. Consider fetching a smaller per-Safe limit (e.g., Math.min(args.limit, MULTI_SAFE_QUEUE_BATCH_SIZE) or a fixed small number) and only fetching more if needed.

3. Sequential batch processing negates concurrency benefits (transactions.service.ts:652-712)
The for loop processes batches of 3 sequentially. With maxOverviews=10, that's 4 serial rounds. Since Promise.allSettled already handles errors gracefully, consider processing all Safes in a single Promise.allSettled with concurrency control (e.g., a semaphore pattern) instead of sequential batches, to reduce total latency.

4. Missing @ApiProperty({ type: Transaction }) on entity (safe-queued-transaction.entity.ts:10)
The transaction property uses @ApiProperty() without specifying the type. Swagger won't be able to infer the nested Transaction schema. Use @ApiProperty({ type: Transaction }).

5. Static constant placement (transactions.service.ts:642)
MULTI_SAFE_QUEUE_BATCH_SIZE is defined mid-file between methods. Move it to the top of the class (before the constructor) to follow the existing code convention.

6. Missing unit tests for service logic
Only integration tests are provided. The getMultiSafeTransactionQueue method has significant logic (batching, error handling, sorting, slicing) that would benefit from unit tests with mocked dependencies - particularly edge cases like partial failures across batches, the maxOverviews cap, and sorting correctness with mixed timestamps.

7. No test for maxOverviews truncation behavior
The service silently truncates the input list to maxOverviews (transactions.service.ts:649). There's no test verifying that excess Safes are ignored, or that the endpoint communicates this limit to callers.


|

description:
'Comma-separated list of Safe addresses in CAIP-10 format (chainId:address)',
example:
'1:0x1234567890123456789012345678901234567890,5:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

QQ: Why don't we accept an object with chainId as the key which has safeAddresses as the values?
e.g.

{
"1" : [{{ADDRESS_1}}, {{ADDRESS_2}}],
"5": [{{ADDRESS_1}}, {{ADDRESS_2}}],
}

Copy link
Copy Markdown
Contributor Author

@clovisdasilvaneto clovisdasilvaneto Mar 25, 2026

Choose a reason for hiding this comment

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

@PooyaRaki because I'm following the same standard we have in the safesOverview endpoint (the one we use to get the number of queued transactions and show in the sidebar)

description:
'Array of queued transactions enriched with Safe address and chain ID',
})
@Get('multi-safe/transactions/queued')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What do you think removing multi-safe? then it would be
@Get('transactions/queued')

const limitedSafes = args.addresses.slice(0, this.maxOverviews);
const transactions: Array<SafeQueuedTransaction> = [];

for (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of using a for loop and then .slice we could simplify it using lodash which is already a dependency, Something like the following:
for (const batch of chunk(limitedSafes, TransactionsService.MULTI_SAFE_QUEUE_BATCH_SIZE)) {

networkService = moduleFixture.get(NetworkService);
loggingService = moduleFixture.get(LoggingService);

jest.spyOn(loggingService, 'error');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since the service is mocked we can safely remove the mocks.

...baseConfiguration.mappings,
safe: {
...baseConfiguration.mappings.safe,
maxOverviews: 10,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should use a random number generated by faker

await app.close();
});

async function buildSafeWithTransaction(args: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: We could extract the builder to another file.

submissionDate: new Date('2024-01-01T00:00:00Z'),
});
const safe2Data = await buildSafeWithTransaction({
nonce: 1,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: We should use faker here.

});
const safe2Data = await buildSafeWithTransaction({
nonce: 1,
submissionDate: new Date('2024-06-01T00:00:00Z'),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can use faker to generate the dates throughout the test file.

Comment on lines +237 to +284
networkService.get.mockImplementation(({ url }) => {
for (const data of allMocks) {
const chainUrl = `${safeConfigUrl}/api/v1/chains/${data.chain.chainId}`;
const safeUrl = `${data.chain.transactionService}/api/v1/safes/${data.safeAddress}`;
const txUrl = `${data.chain.transactionService}/api/v2/safes/${data.safeAddress}/multisig-transactions/`;

if (url === chainUrl) {
return Promise.resolve({
data: rawify(data.chain),
status: 200,
});
}
if (url === safeUrl) {
return Promise.resolve({
data: rawify(data.safe),
status: 200,
});
}
if (url === txUrl) {
return Promise.resolve({
data: rawify({
count: 1,
next: null,
previous: null,
results: [
multisigToJson(data.transaction) as MultisigTransaction,
],
}),
status: 200,
});
}
}
if (url.includes('/api/v1/tokens/')) {
return Promise.resolve({ data: rawify({}), status: 200 });
}
if (url.includes('/api/v1/contracts/')) {
return Promise.resolve({
data: rawify(
pageBuilder().with('results', [contractBuilder().build()]).build(),
),
status: 200,
});
}
if (url.includes('/api/v1/safe-apps/')) {
return Promise.resolve({ data: rawify([]), status: 200 });
}
return Promise.reject(new Error(`Could not match ${url}`));
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should extract all the mock within the tests to another method to keep the test itself short and easy to follow.


const safesParam = `${chain.chainId}:${safeAddress}`;

await request(app.getHttpServer())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should explicitly check trusted and limit in this test as the title suggests.

- Remove 'multi-safe' prefix from queued transactions endpoint
- Simplify batch processing using lodash chunk() utility
- Extract buildSafeWithTransaction helper to separate builder file
- Use faker consistently for test data generation
- Update entity class to use property initialization
- Add multiSafeQueue configuration for cache expiration
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants