Skip to content
Open
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
144 changes: 144 additions & 0 deletions src/__tests__/chaos/horizonDuplicateHash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import * as StellarSdk from '@stellar/stellar-sdk';
import { StellarSubmissionService } from '../../services/stellarSubmissionService';
import { globalMetrics } from '../../lib/metrics';

// Mock logger
jest.mock('../../lib/logger', () => ({
globalLogger: {
child: jest.fn().mockReturnValue({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}),
},
logger: {
child: jest.fn().mockReturnValue({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}),
}
}));

// Mock environment
jest.mock('../../config/env', () => ({
env: {
STELLAR_NETWORK: 'testnet',
STELLAR_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015',
STELLAR_SERVER_SECRET: 'SABERIntegrationTestSecretKey1234567890ABCDEF',
},
}));

describe('Horizon Duplicate Hash Chaos Tests', () => {
let service: StellarSubmissionService;
let mockServer: any;
let metricsIncrementSpy: jest.SpyInstance;

beforeEach(() => {
process.env.STELLAR_SERVER_SECRET = 'SABERIntegrationTestSecretKey1234567890ABCDEF';
jest.clearAllMocks();

metricsIncrementSpy = jest.spyOn(globalMetrics, 'increment');

mockServer = {
getAccount: jest.fn().mockResolvedValue({
accountId: () => 'G-MOCK-PUBLIC-KEY',
sequenceNumber: () => '1',
incrementSequenceNumber: jest.fn(),
}),
sendTransaction: jest.fn(),
};

StellarSdk.rpc.Server = jest.fn(() => mockServer) as any;
StellarSdk.Keypair.fromSecret = jest.fn(() => ({
publicKey: () => 'G-MOCK-PUBLIC-KEY',
sign: jest.fn(),
})) as any;

StellarSdk.Asset.native = jest.fn(() => ({ code: 'XLM', issuer: undefined })) as any;

StellarSdk.TransactionBuilder = jest.fn(() => ({
addOperation: jest.fn().mockReturnThis(),
setTimeout: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue({
hash: () => Buffer.from('mock-hash'),
sign: jest.fn(),
}),
})) as any;

StellarSdk.Operation.payment = jest.fn() as any;
(StellarSdk as any).BASE_FEE = '100';

service = new StellarSubmissionService();
});

afterEach(() => {
metricsIncrementSpy.mockRestore();
});

it('should treat first-attempt duplicate as success (retry recovery scenario)', async () => {
mockServer.sendTransaction.mockResolvedValueOnce({
hash: 'mock-hash',
status: 'DUPLICATE',
latestLedger: 12345,
latestLedgerCloseTime: 1234567890,
});

const result = await service.submitPayment('G-DEST', '10.0');

expect(result.status).toBe('DUPLICATE');
expect(metricsIncrementSpy).toHaveBeenCalledWith('submission.duplicate.recovered', 1);
});

it('should treat true-duplicate as success without double persisting (client bug)', async () => {
mockServer.sendTransaction.mockResolvedValueOnce({
hash: 'mock-hash',
status: 'DUPLICATE',
latestLedger: 12345,
latestLedgerCloseTime: 1234567890,
});

const result = await service.submitPayment('G-DEST', '10.0');

expect(result.status).toBe('DUPLICATE');
expect(metricsIncrementSpy).toHaveBeenCalledWith('submission.duplicate.recovered', 1);

expect(result).toBeDefined();

expect(service.getTransactionCacheSize()).toBe(1);
});

it('Concurrent duplicate submissions from two workers coalesce', async () => {
mockServer.sendTransaction
.mockResolvedValueOnce({
hash: 'mock-hash',
status: 'PENDING',
latestLedger: 12345,
latestLedgerCloseTime: 1234567890,
})
.mockResolvedValueOnce({
hash: 'mock-hash',
status: 'DUPLICATE',
latestLedger: 12345,
latestLedgerCloseTime: 1234567890,
});

const worker1 = service;
const worker2 = new StellarSubmissionService();

const [res1, res2] = await Promise.all([
worker1.submitPayment('G-DEST', '10.0'),
worker2.submitPayment('G-DEST', '10.0'),
]);

expect(res1.status).toBe('PENDING');
expect(res2.status).toBe('DUPLICATE');

expect(metricsIncrementSpy).toHaveBeenCalledWith('submission.duplicate.recovered', 1);

expect(res1).toBeDefined();
expect(res2).toBeDefined();
});
});
117 changes: 117 additions & 0 deletions src/security/attestationVerifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { verifyReproducibleBuildAttestation } from './attestationVerifier';

describe('attestationVerifier', () => {
const allowedBuilderIds = ['https://github.com/RevoraOrg/builder', 'https://github.com/trusted/builder'];
const targetCodeId = 'abc123def456';

it('should verify a valid attestation with matching builder and digest', () => {
const validAttestation = {
builder: { id: 'https://github.com/RevoraOrg/builder' },
predicateType: 'https://slsa.dev/provenance/v0.2',
subject: [
{
name: 'contract.wasm',
digest: { sha256: targetCodeId }
}
]
};

const result = verifyReproducibleBuildAttestation(validAttestation, targetCodeId, allowedBuilderIds);
expect(result.builderId).toBe('https://github.com/RevoraOrg/builder');
expect(result.subjectDigest).toBe(targetCodeId);
});

it('should reject attestation from unknown builder', () => {
const unknownBuilderAttestation = {
builder: { id: 'https://github.com/malicious/builder' },
predicateType: 'https://slsa.dev/provenance/v0.2',
subject: [
{
name: 'contract.wasm',
digest: { sha256: targetCodeId }
}
]
};

expect(() => {
verifyReproducibleBuildAttestation(unknownBuilderAttestation, targetCodeId, allowedBuilderIds);
}).toThrow('Attestation builder identity is not authorized for tenant');
});

it('should reject attestation missing builder id', () => {
const missingBuilderAttestation = {
predicateType: 'https://slsa.dev/provenance/v0.2',
subject: [
{
name: 'contract.wasm',
digest: { sha256: targetCodeId }
}
]
};

expect(() => {
verifyReproducibleBuildAttestation(missingBuilderAttestation, targetCodeId, allowedBuilderIds);
}).toThrow('Attestation missing builder.id');
});

it('should reject attestation not matching target code id', () => {
const mismatchAttestation = {
builder: { id: 'https://github.com/RevoraOrg/builder' },
predicateType: 'https://slsa.dev/provenance/v0.2',
subject: [
{
name: 'contract.wasm',
digest: { sha256: 'someotherdigest' }
}
]
};

expect(() => {
verifyReproducibleBuildAttestation(mismatchAttestation, targetCodeId, allowedBuilderIds);
}).toThrow('Attestation subject payload does not contain a matching target code identifier');
});

it('should accept when name matches target code id but digest does not', () => {
const nameMatchAttestation = {
builder: { id: 'https://github.com/RevoraOrg/builder' },
predicateType: 'https://slsa.dev/provenance/v0.2',
subject: [
{
name: targetCodeId,
digest: { sha256: 'someotherdigest' }
}
]
};

const result = verifyReproducibleBuildAttestation(nameMatchAttestation, targetCodeId, allowedBuilderIds);
expect(result.builderId).toBe('https://github.com/RevoraOrg/builder');
expect(result.subjectName).toBe(targetCodeId);
});

it('should reject unsupported predicate type', () => {
const badPredicateAttestation = {
builder: { id: 'https://github.com/RevoraOrg/builder' },
predicateType: 'https://slsa.dev/provenance/v1.0', // unsupported
subject: [
{
name: 'contract.wasm',
digest: { sha256: targetCodeId }
}
]
};

expect(() => {
verifyReproducibleBuildAttestation(badPredicateAttestation, targetCodeId, allowedBuilderIds);
}).toThrow('Unsupported attestation predicate type');
});

it('should reject if attestation is not an object', () => {
expect(() => {
verifyReproducibleBuildAttestation(null, targetCodeId, allowedBuilderIds);
}).toThrow('Attestation must be an object');

expect(() => {
verifyReproducibleBuildAttestation('string_attestation', targetCodeId, allowedBuilderIds);
}).toThrow('Attestation must be an object');
});
});
8 changes: 6 additions & 2 deletions src/services/stellarSubmissionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
shouldRetryStellarRPCFailure,
createStellarErrorResponse
} from '../lib/stellarRpcFailure';
import { globalMetrics } from '../lib/metrics';

const logger = globalLogger.child({ service: 'stellar-submission' });

Expand Down Expand Up @@ -265,10 +266,13 @@ export class StellarSubmissionService {
if (result.status === 'PENDING') {
return result;
} else if (result.status === 'DUPLICATE') {
throw Errors.conflict('Transaction already submitted', {
hash: result.hash,
globalMetrics.increment('submission.duplicate.recovered', 1);
logger.info('Recovered duplicate transaction submission', {
transactionHash,
attemptCount,
operation: 'send_transaction',
});
return result;
} else if (result.status === 'TRY_AGAIN_LATER') {
throw Errors.serviceUnavailable('Transaction rate limited, try again later');
} else {
Expand Down
Loading