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
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,70 @@ describe('InputSanitizerService', () => {
});

describe('validateContractId', () => {
it('accepts valid Stellar contract C-addresses', () => {
it('accepts a valid Stellar contract C-address', () => {
expect(service.validateContractId(validContractId)).toBe(validContractId);
});

it('trims surrounding whitespace and returns the bare C-address', () => {
expect(service.validateContractId(` ${validContractId} `)).toBe(
validContractId,
);
});

it('rejects malformed contract IDs with format guidance', () => {
it('rejects a string of 56 repeated "a" characters (not a valid C-address)', () => {
expect(() => service.validateContractId('a'.repeat(56))).toThrow(
/contract C-address with a correct StrKey checksum/,
);
});

it('rejects a G-address (public key) passed as a contract ID', () => {
expect(() =>
service.validateContractId(validStellarPublicKey),
).toThrow(BadRequestException);
});

it('rejects a 64-character hex string (not a Stellar C-address)', () => {
expect(() =>
service.validateContractId('a'.repeat(64)),
).toThrow(BadRequestException);
});

it('rejects an odd-length hex string', () => {
expect(() =>
service.validateContractId('abc'),
).toThrow(BadRequestException);
});

it('rejects a string with non-hex, non-base32 characters', () => {
expect(() =>
service.validateContractId('ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ'),
).toThrow(BadRequestException);
});

it('rejects an empty string', () => {
expect(() => service.validateContractId('')).toThrow(BadRequestException);
});

it('rejects a whitespace-only string', () => {
expect(() => service.validateContractId(' ')).toThrow(
BadRequestException,
);
});

it.each([null, undefined, 42, {}, []])(
'rejects non-string value %p',
(value) => {
expect(() =>
service.validateContractId(value as unknown as string),
).toThrow(BadRequestException);
},
);

it('includes example C-address format in the empty-input error message', () => {
expect(() => service.validateContractId('')).toThrow(
/C-address format/,
);
});
});

describe('validateAmount', () => {
Expand Down
4 changes: 2 additions & 2 deletions harvest-finance/backend/src/soroban/dto/soroban-events.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,15 @@ export class QuerySorobanEventsDto {
skip?: number = 0;

@ApiPropertyOptional({
description: 'Max records to return (1-200)',
description: 'Max records to return (1-100)',
example: 50,
default: 50,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
@Max(100)
limit?: number = 50;
}

Expand Down
133 changes: 104 additions & 29 deletions harvest-finance/backend/src/stellar/utils/stellar-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,126 @@
import { isRetryableStellarError } from './stellar-retry';

describe('isRetryableStellarError', () => {
it('does not retry deterministic Stellar rejections (result_codes present)', () => {
const err = {
response: {
status: 400,
data: { extras: { result_codes: { transaction: 'tx_failed' } } },
},
};
expect(isRetryableStellarError(err)).toBe(false);
describe('result_codes — deterministic Stellar rejections', () => {
it('does not retry when result_codes contains a transaction code', () => {
const err = {
response: {
status: 400,
data: { extras: { result_codes: { transaction: 'tx_failed' } } },
},
};
expect(isRetryableStellarError(err)).toBe(false);
});

it('does not retry when result_codes contains operation codes', () => {
const err = {
response: {
status: 400,
data: {
extras: {
result_codes: {
transaction: 'tx_failed',
operations: ['op_no_trust'],
},
},
},
},
};
expect(isRetryableStellarError(err)).toBe(false);
});

it('does not retry tx_bad_seq even on a 5xx response when result_codes is present', () => {
const err = {
response: {
status: 500,
data: { extras: { result_codes: { transaction: 'tx_bad_seq' } } },
},
};
expect(isRetryableStellarError(err)).toBe(false);
});
});

it('retries on HTTP 429 (rate limited)', () => {
expect(isRetryableStellarError({ response: { status: 429 } })).toBe(true);
describe('HTTP 429 — rate limiting', () => {
it('retries on HTTP 429', () => {
expect(isRetryableStellarError({ response: { status: 429 } })).toBe(true);
});
});

it('retries on HTTP 5xx', () => {
expect(isRetryableStellarError({ response: { status: 502 } })).toBe(true);
expect(isRetryableStellarError({ response: { status: 504 } })).toBe(true);
describe('HTTP 5xx — server / gateway errors', () => {
it.each([500, 502, 503, 504, 599])(
'retries on HTTP %i',
(status) => {
expect(isRetryableStellarError({ response: { status } })).toBe(true);
},
);

it('does not retry on HTTP 600 (outside 5xx range)', () => {
expect(isRetryableStellarError({ response: { status: 600 } })).toBe(false);
});
});

it('does not retry on other 4xx', () => {
expect(isRetryableStellarError({ response: { status: 400 } })).toBe(false);
expect(isRetryableStellarError({ response: { status: 404 } })).toBe(false);
expect(isRetryableStellarError({ response: { status: 401 } })).toBe(false);
describe('HTTP 4xx — client errors (non-retryable)', () => {
it.each([400, 401, 403, 404, 409])(
'does not retry on HTTP %i',
(status) => {
expect(isRetryableStellarError({ response: { status } })).toBe(false);
},
);
});

it('retries on transient network error codes', () => {
for (const code of [
describe('transient network error codes', () => {
it.each([
'ECONNRESET',
'ECONNREFUSED',
'ECONNABORTED',
'ETIMEDOUT',
'EAI_AGAIN',
]) {
'ENETUNREACH',
'EHOSTUNREACH',
'EPIPE',
])('retries on error code %s', (code) => {
expect(isRetryableStellarError({ code })).toBe(true);
}
});

it('does not retry on an unrecognised network error code', () => {
expect(isRetryableStellarError({ code: 'EUNKNOWN' })).toBe(false);
});
});

it('retries on errors whose message mentions a timeout', () => {
expect(
isRetryableStellarError({ message: 'Request timeout exceeded' }),
).toBe(true);
describe('timeout messages', () => {
it.each([
'Request timeout exceeded',
'TIMEOUT',
'connection Timeout',
'read timeout after 30s',
])('retries when message is "%s"', (message) => {
expect(isRetryableStellarError({ message })).toBe(true);
});

it('does not retry when message does not mention timeout', () => {
expect(isRetryableStellarError({ message: 'Bad request' })).toBe(false);
});
});

it('does not retry on unknown / non-network errors', () => {
expect(isRetryableStellarError(new Error('something else'))).toBe(false);
expect(isRetryableStellarError(null)).toBe(false);
expect(isRetryableStellarError('string error')).toBe(false);
describe('non-retryable / edge cases', () => {
it('does not retry on a plain Error with no network context', () => {
expect(isRetryableStellarError(new Error('something else'))).toBe(false);
});

it('does not retry on null', () => {
expect(isRetryableStellarError(null)).toBe(false);
});

it('does not retry on a primitive string', () => {
expect(isRetryableStellarError('string error')).toBe(false);
});

it('does not retry on a number', () => {
expect(isRetryableStellarError(500)).toBe(false);
});

it('does not retry on an empty object', () => {
expect(isRetryableStellarError({})).toBe(false);
});
});
});
4 changes: 3 additions & 1 deletion harvest-finance/backend/src/vaults/vaults.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,13 @@ export class VaultsService {
throw new NotFoundException('Deposit not found');
}

const stellarTransactionId: string | null = `mock_stellar_${Date.now()}`;

await this.depositRepository.update(depositId, {
status: DepositStatus.CONFIRMED,
confirmedAt: new Date(),
transactionHash: `mock_tx_${Date.now()}`,
stellarTransactionId: `mock_stellar_${Date.now()}`,
...(stellarTransactionId != null ? { stellarTransactionId } : {}),
});

const updatedDeposit = await this.depositRepository.findOne({
Expand Down
Loading