Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions backend/src/dispute/arweave-archive.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,34 @@ export class ArweaveArchiveService implements OnModuleInit {
['Step', saga.currentStep],
];

let uploadData = jsonString;
if (jsonString.length > MAX_JSON_SIZE) {
this.logger.warn(`Archive data exceeds ${MAX_JSON_SIZE / 1024}KB, truncating for Arweave bundle`);
this.logger.warn(
`Archive data exceeds ${MAX_JSON_SIZE / 1024}KB, truncating votes and stepHistory for Arweave bundle`,
);
const truncated = {
...archiveData,
votes: (archiveData.votes as unknown[]).slice(0, 10),
stepHistory: (archiveData.stepHistory as unknown[]).slice(0, 20),
_truncated: true,
};
uploadData = JSON.stringify(truncated);
if (uploadData.length > MAX_JSON_SIZE) {
uploadData = JSON.stringify({
sagaId: archiveData.sagaId,
escrowId: archiveData.escrowId,
initiator: archiveData.initiator,
verdict: archiveData.verdict,
currentStep: archiveData.currentStep,
createdAt: archiveData.createdAt,
completedAt: archiveData.completedAt,
_truncated: true,
_reason: 'archive exceeded max size after initial truncation',
});
}
}

const txId = await this.uploader.upload(jsonString, tags);
const txId = await this.uploader.upload(uploadData, tags);

this.metrics?.increment('arweave_archive_success_total', { result: 'success' });
this.logger.log(`Arweave archive uploaded for saga ${saga.sagaId}, txId: ${txId}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,8 @@ export class EscrowReconciliationService {
const drifts: DriftRecord[] = [];
const errors: ReconciliationError[] = [];

// Use cursor-based pagination to load escrows in batches instead of loading all at once
const BATCH_SIZE = 50;
const linked: Escrow[] = [];
let offset = 0;
let hasMore = true;

while (hasMore) {
const batch = await this.escrowService.findAll();
const batchLinked = batch.slice(offset, offset + BATCH_SIZE).filter(e => e.contractEscrowId);
linked.push(...batchLinked);
offset += BATCH_SIZE;
hasMore = offset < batch.length;
}
const all = await this.escrowService.findAll();
const linked = all.filter(e => e.contractEscrowId);

const knownIds = new Set(linked.map(e => e.contractEscrowId as string));

Expand Down
43 changes: 33 additions & 10 deletions backend/src/escrow/graphql/escrow-graphql.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
import request from 'supertest';
import { INestApplication } from '@nestjs/common';
import { EscrowResolver, MilestoneResolver, DeliverableResolver } from './escrow.resolver';
import { EscrowResolver, MilestoneResolver, DeliverableResolver, EscrowPage } from './escrow.resolver';
import { EscrowType } from './types/escrow.type';
import { MilestoneType } from './types/milestone.type';
import { DeliverableType } from './types/deliverable.type';
Expand Down Expand Up @@ -57,6 +57,7 @@ describe('EscrowGraphQL Integration', () => {
EscrowResolver,
MilestoneResolver,
DeliverableResolver,
EscrowPage,
{ provide: EscrowService, useValue: mockEscrowService },
{ provide: MilestoneLoader, useValue: mockMilestoneLoader },
{ provide: DeliverableLoader, useValue: mockDeliverableLoader },
Expand All @@ -80,18 +81,19 @@ describe('EscrowGraphQL Integration', () => {
// ─── escrows query ─────────────────────────────────────────────────────────

describe('escrows query', () => {
it('returns an empty array when no escrows exist', async () => {
it('returns an empty page when no escrows exist', async () => {
mockEscrowService.findAll.mockResolvedValue([]);

const res = await request(app.getHttpServer())
.post('/graphql')
.send({ query: `{ escrows { id status } }` });
.send({ query: `{ escrows { data { id status } total offset limit } }` });

expect(res.status).toBe(200);
expect(res.body.data.escrows).toEqual([]);
expect(res.body.data.escrows.data).toEqual([]);
expect(res.body.data.escrows.total).toBe(0);
});

it('returns escrows with scalar fields', async () => {
it('returns escrows with scalar fields and pagination metadata', async () => {
const escrows = [
{
id: 'esc-1',
Expand All @@ -114,12 +116,33 @@ describe('EscrowGraphQL Integration', () => {

const res = await request(app.getHttpServer())
.post('/graphql')
.send({ query: `{ escrows { id depositor beneficiary amountXLM status createdAt } }` });
.send({ query: `{ escrows { data { id depositor beneficiary amountXLM status createdAt } total offset limit } }` });

expect(res.status).toBe(200);
expect(res.body.data.escrows).toHaveLength(2);
expect(res.body.data.escrows[0].id).toBe('esc-1');
expect(res.body.data.escrows[1].status).toBe('pending');
expect(res.body.data.escrows.data).toHaveLength(2);
expect(res.body.data.escrows.data[0].id).toBe('esc-1');
expect(res.body.data.escrows.data[1].status).toBe('pending');
expect(res.body.data.escrows.total).toBe(2);
});

it('respects offset and limit args', async () => {
const escrows = [
{ id: 'esc-1', depositor: 'GABC', beneficiary: 'GDEF', amountXLM: '100', status: 'active', createdAt: '2026-01-01T00:00:00.000Z' },
{ id: 'esc-2', depositor: 'GGHI', beneficiary: 'GJKL', amountXLM: '200', status: 'pending', createdAt: '2026-01-02T00:00:00.000Z' },
{ id: 'esc-3', depositor: 'GMNO', beneficiary: 'GPQR', amountXLM: '300', status: 'released', createdAt: '2026-01-03T00:00:00.000Z' },
];
mockEscrowService.findAll.mockResolvedValue(escrows);

const res = await request(app.getHttpServer())
.post('/graphql')
.send({ query: `{ escrows(offset: 1, limit: 1) { data { id } total offset limit } }` });

expect(res.status).toBe(200);
expect(res.body.data.escrows.data).toHaveLength(1);
expect(res.body.data.escrows.data[0].id).toBe('esc-2');
expect(res.body.data.escrows.total).toBe(3);
expect(res.body.data.escrows.offset).toBe(1);
expect(res.body.data.escrows.limit).toBe(1);
});
});

Expand Down Expand Up @@ -291,7 +314,7 @@ describe('EscrowGraphQL Integration', () => {

const res = await request(app.getHttpServer())
.post('/graphql')
.send({ query: `{ escrows { id milestones { id } } }` });
.send({ query: `{ escrows { data { id milestones { id } } } }` });

expect(res.status).toBe(200);
// DataLoader should have been called once per unique escrow ID
Expand Down
35 changes: 31 additions & 4 deletions backend/src/escrow/graphql/escrow.resolver.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Resolver, Query, Args, ResolveField, Parent } from '@nestjs/graphql';
import { Resolver, Query, Args, ResolveField, Parent, Int, ObjectType, Field } from '@nestjs/graphql';
import { EscrowType } from './types/escrow.type';
import { MilestoneType } from './types/milestone.type';
import { DeliverableType } from './types/deliverable.type';
Expand All @@ -11,6 +11,21 @@ import { JurorVoteLoader } from './dataloader/juror-vote.loader';
import { UserProfileLoader } from './dataloader/user-profile.loader';
import { Inject } from '@nestjs/common';

@ObjectType()
export class EscrowPage {
@Field(() => [EscrowType])
data!: EscrowType[];

@Field(() => Int)
total!: number;

@Field(() => Int)
offset!: number;

@Field(() => Int)
limit!: number;
}

@Resolver(() => EscrowType)
export class EscrowResolver {
constructor(
Expand All @@ -21,9 +36,21 @@ export class EscrowResolver {
@Inject(UserProfileLoader) private readonly userProfileLoader: UserProfileLoader,
) {}

@Query(() => [EscrowType])
async escrows(): Promise<EscrowType[]> {
return this.escrowService.findAll() as unknown as EscrowType[];
@Query(() => EscrowPage)
async escrows(
@Args('offset', { type: () => Int, nullable: true, defaultValue: 0 }) offset: number,
@Args('limit', { type: () => Int, nullable: true, defaultValue: 20 }) limit: number,
): Promise<EscrowPage> {
const safeOffset = Math.max(0, offset);
const safeLimit = Math.min(Math.max(1, limit), 100);
const all = await this.escrowService.findAll();
const data = all.slice(safeOffset, safeOffset + safeLimit);
return {
data: data as unknown as EscrowType[],
total: all.length,
offset: safeOffset,
limit: safeLimit,
};
}

@Query(() => EscrowType, { nullable: true })
Expand Down
3 changes: 2 additions & 1 deletion backend/src/reputation/reputation.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { INestApplication } from '@nestjs/common';
import { INestApplication, VersioningType } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { ReputationController } from './reputation.controller';
Expand Down Expand Up @@ -91,6 +91,7 @@ describe('ReputationController Supertest integration', () => {
}).compile();

app = module.createNestApplication();
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
await app.init();
});

Expand Down