From ec7a31b87de3fc72ab6d26dd76ea33aa9e1996e5 Mon Sep 17 00:00:00 2001 From: TheDEV111 Date: Thu, 22 Oct 2026 12:12:10 +0100 Subject: [PATCH] test: add deterministic ledger sequence fixtures for #346 --- CONTRIBUTING.md | 6 +- docs/LEDGER_SEQUENCE_FIXTURES.md | 54 ++++ .../fixtures/ledger-sequence.fixtures.ts | 140 +++++++++++ src/testing/fixtures/ledger-sequence.spec.ts | 233 ++++++++++++++++++ 4 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 docs/LEDGER_SEQUENCE_FIXTURES.md create mode 100644 src/testing/fixtures/ledger-sequence.fixtures.ts create mode 100644 src/testing/fixtures/ledger-sequence.spec.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b108164..5120de1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,6 +130,10 @@ the processor per request with `X-Payment-Processor: sandbox` (or `NODE_ENV=production`. See [docs/SANDBOX_MODE.md](docs/SANDBOX_MODE.md) for the available scenarios, configuration and limitations. +For oracle confirmation thresholds, stale RPC heads, retry recovery, and +ledger entry expiry boundaries, use the fixed offline corpus documented in +[Ledger sequence fixtures](docs/LEDGER_SEQUENCE_FIXTURES.md). + ### Environment Configuration The API validates all required secrets at startup. Invalid or placeholder values @@ -268,4 +272,4 @@ If you're interested in becoming a maintainer, reach out to the core team! We're --- -Happy coding! 🚀 \ No newline at end of file +Happy coding! 🚀 diff --git a/docs/LEDGER_SEQUENCE_FIXTURES.md b/docs/LEDGER_SEQUENCE_FIXTURES.md new file mode 100644 index 00000000..1fb6f93b --- /dev/null +++ b/docs/LEDGER_SEQUENCE_FIXTURES.md @@ -0,0 +1,54 @@ +# Deterministic ledger sequence fixtures (#346) + +`src/testing/fixtures/ledger-sequence.fixtures.ts` supplies reusable oracle +transactions and explicit confirmation/TTL boundary cases. The transaction uses +a fixed test identity, account sequence, Testnet passphrase, invocation arguments, +and time bounds. Rebuilding it produces the same XDR and transaction/payload +hashes without a live RPC, secrets, database, or wall clock. The deterministic +identity is public test data: never fund it or use it in production. The envelope +is unsigned; these fixtures test off-chain verification, not on-chain signature +authorization or contract execution. + +Run the executable verification locally: + +```bash +npm test -- --runInBand src/testing/fixtures/ledger-sequence.spec.ts +``` + +Import `createLedgerSequenceFixture`, `CONFIRMATION_FIXTURES`, or +`LEDGER_TTL_FIXTURES` into another test to reuse the same inputs. Each factory +call creates a fresh transaction and submission reference. The existing +`fixtures:generate` output and its five scenario contracts remain unchanged. + +## Boundaries and recovery + +| Case | Existing behavior | Operational response | +| --- | --- | --- | +| Inclusion ledger | Counts as confirmation one | Wait for `ORACLE_MIN_CONFIRMATIONS`; do not add an extra ledger to the threshold. | +| One ledger short / stale RPC head | Retryable `INSUFFICIENT_CONFIRMATIONS` | Keep the original submission pending and verify it again after the head advances or the RPC catches up. Do not resubmit the transaction. | +| Exact threshold / above threshold | Verification succeeds | The fixture asserts the existing hash, timestamp, and ledger response. | +| uint32 upper boundary | Confirmation arithmetic stays exact | The corpus covers 4294967294–4294967295; it assumes no sequence wraparound. | +| Transaction not found | Retryable `TRANSACTION_NOT_FOUND` | Recheck the original hash; if outside RPC retention, use an archival RPC. Missing data alone does not prove failure. | +| Transaction failed | Nonretryable `TRANSACTION_FAILED` | Investigate the ledger failure before preparing a new transaction. | +| HTTP 429 / transient head read failure | Bounded exponential retry | Retry the read; the transaction is not submitted or reread within that attempt. Exhaustion returns retryable `RPC_UNAVAILABLE`. | +| RPC HTTP 403 | Nonretryable adapter `RPC_ERROR`; no immediate retry | Repair RPC access credentials. The verifier aborts the cycle without rejecting the pending submission for an infrastructure error. | +| Different registered submitter | Nonretryable `PAYLOAD_MISMATCH` | Investigate the registered reference; confirmations never override submitter binding. | +| TTL equals current ledger | Entry remains live | Expiry is inclusive. | +| Persistent TTL below current ledger (including placeholder zero) | Archived | Restore through the authorized operational workflow before new contract calls. | +| Temporary TTL below current ledger | Missing, not archived | Recreate through the application workflow; restoration cannot recover expired temporary data. | + +The integration test runs the actual adapter inside `SubmissionVerifierService` +with in-memory history/audit doubles: a short confirmation count calls `defer` +without marking success or rejection; a later cycle verifies the same hash at +the threshold. RPC methods are stubbed at the SDK boundary. Authorization tests +cover RPC access refusal and submitter reference binding; they do not claim to +exercise HTTP guards or the on-chain submitter allow-list. + +## Compatibility and rollback + +This is a test-only addition. No routes, DTOs, database schemas, production retry +policies, or public responses change, and no migration is required. Tests never +submit transactions or write production state. Rollback consists of reverting +the fixture, test, and documentation additions; no ledger or database rollback +is needed. Do not treat the fixture's historical time bounds as a transaction +template suitable for submission. diff --git a/src/testing/fixtures/ledger-sequence.fixtures.ts b/src/testing/fixtures/ledger-sequence.fixtures.ts new file mode 100644 index 00000000..bb88abf4 --- /dev/null +++ b/src/testing/fixtures/ledger-sequence.fixtures.ts @@ -0,0 +1,140 @@ +import { + Account, + Address, + Contract, + Keypair, + Networks, + nativeToScVal, + StrKey, + TransactionBuilder, +} from "@stellar/stellar-sdk"; +import { hashOracleArguments } from "src/blockchain/oracle/services/stellar-oracle.adapter"; + +/** Public test identity only; never fund or use this deterministic key on-chain. */ +export const LEDGER_FIXTURE_SUBMITTER = Keypair.fromRawEd25519Seed( + Buffer.alloc(32, 42), +).publicKey(); +export const LEDGER_FIXTURE_CONTRACT = StrKey.encodeContract( + Buffer.alloc(32, 1), +); + +/** Fixed time bounds and account sequence make XDR and hashes replayable. */ +export function createLedgerSequenceFixture() { + const args = [ + new Address(LEDGER_FIXTURE_SUBMITTER).toScVal(), + nativeToScVal("XLM_USD", { type: "symbol" }), + nativeToScVal(100n, { type: "i128" }), + nativeToScVal(2, { type: "u32" }), + nativeToScVal(1700000000n, { type: "u64" }), + nativeToScVal(1n, { type: "u64" }), + ]; + const transaction = new TransactionBuilder( + new Account(LEDGER_FIXTURE_SUBMITTER, "429496729600"), + { fee: "100", networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + new Contract(LEDGER_FIXTURE_CONTRACT).call("submit_price", ...args), + ) + .setTimebounds(1700000000, 1700000030) + .build(); + return { + transaction, + submission: { + submitter: LEDGER_FIXTURE_SUBMITTER, + payloadHash: hashOracleArguments(args), + transactionHash: Buffer.from(transaction.hash()).toString("hex"), + }, + }; +} + +/** Ledger inclusion counts as the first confirmation. Max is Stellar uint32. */ +export const CONFIRMATION_FIXTURES = [ + { name: "first ledger", ledger: 1, latest: 1, minimum: 1, confirmed: true }, + { + name: "inclusion ledger", + ledger: 100, + latest: 100, + minimum: 2, + confirmed: false, + }, + { + name: "exact threshold", + ledger: 100, + latest: 101, + minimum: 2, + confirmed: true, + }, + { + name: "above threshold", + ledger: 100, + latest: 102, + minimum: 2, + confirmed: true, + }, + { + name: "stale RPC head", + ledger: 100, + latest: 99, + minimum: 1, + confirmed: false, + }, + { + name: "uint32 boundary pending", + ledger: 4294967294, + latest: 4294967294, + minimum: 2, + confirmed: false, + }, + { + name: "uint32 boundary confirmed", + ledger: 4294967294, + latest: 4294967295, + minimum: 2, + confirmed: true, + }, +] as const; + +export const LEDGER_TTL_FIXTURES = [ + { + name: "before expiry", + latest: 99, + ttl: 100, + temporary: false, + expected: "live", + }, + { + name: "expiry ledger inclusive", + latest: 100, + ttl: 100, + temporary: false, + expected: "live", + }, + { + name: "persistent expired", + latest: 101, + ttl: 100, + temporary: false, + expected: "archived", + }, + { + name: "temporary expired", + latest: 101, + ttl: 100, + temporary: true, + expected: "missing", + }, + { + name: "archived placeholder", + latest: 100, + ttl: 0, + temporary: false, + expected: "archived", + }, + { + name: "maximum live ledger", + latest: 4294967295, + ttl: 4294967295, + temporary: false, + expected: "live", + }, +] as const; diff --git a/src/testing/fixtures/ledger-sequence.spec.ts b/src/testing/fixtures/ledger-sequence.spec.ts new file mode 100644 index 00000000..854677f1 --- /dev/null +++ b/src/testing/fixtures/ledger-sequence.spec.ts @@ -0,0 +1,233 @@ +import { ConfigService } from "@nestjs/config"; +import { Contract, Keypair, Networks, rpc, xdr } from "@stellar/stellar-sdk"; +import { + classifyLedgerEntry, + StellarOracleAdapter, +} from "src/blockchain/oracle/services/stellar-oracle.adapter"; +import { SubmissionVerifierService } from "src/blockchain/oracle/submission-verifier.service"; +import { SubmissionHistoryService } from "src/blockchain/oracle/services/submission-history.service"; +import { AuditLogService } from "src/infrastructure/audit/audit-log.service"; +import { + CONFIRMATION_FIXTURES, + createLedgerSequenceFixture, + LEDGER_FIXTURE_CONTRACT, + LEDGER_TTL_FIXTURES, +} from "./ledger-sequence.fixtures"; + +jest.mock("src/observability/telemetry.service", () => ({ + telemetryService: { startTimer: () => jest.fn() }, +})); + +describe("deterministic ledger sequence fixtures (#346)", () => { + const fixture = createLedgerSequenceFixture(); + let transaction: jest.SpyInstance; + let latest: jest.SpyInstance; + let adapter: StellarOracleAdapter; + + function configuredAdapter(minimum = 2) { + return new StellarOracleAdapter( + new ConfigService({ + SOROBAN_RPC_URL: "https://rpc.example.com", + ORACLE_CONTRACT_ADDRESS: LEDGER_FIXTURE_CONTRACT, + ORACLE_MIN_CONFIRMATIONS: minimum, + ORACLE_RPC_MAX_RETRIES: 2, + ORACLE_RPC_RETRY_DELAY_MS: 1, + }), + ); + } + + function successfulTransaction(ledger = 100) { + return { + status: rpc.Api.GetTransactionStatus.SUCCESS, + ledger, + createdAt: 1700000000, + envelopeXdr: fixture.transaction.toEnvelope(), + }; + } + + beforeEach(() => { + jest.spyOn(rpc.Server.prototype, "getNetwork").mockResolvedValue({ + passphrase: Networks.TESTNET, + } as Awaited>); + transaction = jest + .spyOn(rpc.Server.prototype, "getTransaction") + .mockResolvedValue( + successfulTransaction() as Awaited< + ReturnType + >, + ); + latest = jest + .spyOn(rpc.Server.prototype, "getLatestLedger") + .mockResolvedValue({ sequence: 101 } as Awaited< + ReturnType + >); + adapter = configuredAdapter(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it("recreates identical XDR and hashes independently of wall clock and randomness", () => { + jest.spyOn(Date, "now").mockReturnValue(2000000000000); + jest.spyOn(Keypair, "random").mockImplementation(() => { + throw new Error("random identity"); + }); + const replay = createLedgerSequenceFixture(); + expect(replay.transaction.toEnvelope().toXdr("base64")).toBe( + fixture.transaction.toEnvelope().toXdr("base64"), + ); + expect(replay.submission).toEqual(fixture.submission); + expect(replay.transaction.sequence).toBe("429496729601"); + }); + + it.each(CONFIRMATION_FIXTURES)( + "$name", + async ({ ledger, latest: head, minimum, confirmed }) => { + transaction.mockResolvedValue(successfulTransaction(ledger)); + latest.mockResolvedValue({ sequence: head }); + const result = configuredAdapter(minimum).verify(fixture.submission); + if (confirmed) { + await expect(result).resolves.toEqual({ + hash: fixture.submission.payloadHash, + timestamp: 1700000000000, + ledger, + }); + } else { + await expect(result).rejects.toMatchObject({ + code: "INSUFFICIENT_CONFIRMATIONS", + retryable: true, + }); + } + expect(transaction).toHaveBeenCalledTimes(1); + }, + ); + + it.each(LEDGER_TTL_FIXTURES)( + "$name", + ({ latest: head, ttl, temporary, expected }) => { + const key = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Contract(LEDGER_FIXTURE_CONTRACT) + .address() + .toScAddress(), + key: xdr.ScVal.scvSymbol("feed_his"), + durability: temporary + ? xdr.ContractDataDurability.temporary + : xdr.ContractDataDurability.persistent, + }), + ); + const result = classifyLedgerEntry( + key, + key.toXdr("base64"), + { + key, + val: {} as rpc.Api.LedgerEntryResult["val"], + lastModifiedLedgerSeq: 1, + liveUntilLedgerSeq: ttl, + }, + head, + ); + expect(result).toEqual({ + key: key.toXdr("base64"), + state: expected, + liveUntilLedger: ttl, + lastModifiedLedger: 1, + }); + }, + ); + + it.each([ + [rpc.Api.GetTransactionStatus.NOT_FOUND, "TRANSACTION_NOT_FOUND", true], + [rpc.Api.GetTransactionStatus.FAILED, "TRANSACTION_FAILED", false], + ])( + "handles transaction status %s before reading the head", + async (status, code, retryable) => { + transaction.mockResolvedValue({ status }); + await expect(adapter.verify(fixture.submission)).rejects.toMatchObject({ + code, + retryable, + }); + expect(latest).not.toHaveBeenCalled(); + }, + ); + + it("retries a transient head read without re-reading the transaction", async () => { + jest.useFakeTimers(); + latest.mockRejectedValueOnce({ response: { status: 429 } }); + const result = adapter.verify(fixture.submission); + await jest.runAllTimersAsync(); + await expect(result).resolves.toMatchObject({ ledger: 100 }); + expect(latest).toHaveBeenCalledTimes(2); + expect(transaction).toHaveBeenCalledTimes(1); + }); + + it("bounds retries when the head remains unavailable", async () => { + jest.useFakeTimers(); + latest.mockRejectedValue({ code: "ETIMEDOUT" }); + const assertion = expect( + adapter.verify(fixture.submission), + ).rejects.toMatchObject({ code: "RPC_UNAVAILABLE", retryable: true }); + await jest.runAllTimersAsync(); + await assertion; + expect(latest).toHaveBeenCalledTimes(3); + }); + + it("does not retry RPC authorization failures", async () => { + latest.mockRejectedValue({ + response: { status: 403 }, + message: "Forbidden", + }); + await expect(adapter.verify(fixture.submission)).rejects.toMatchObject({ + code: "RPC_ERROR", + retryable: false, + }); + expect(latest).toHaveBeenCalledTimes(1); + }); + + it("rejects a different registered submitter even at the confirmation threshold", async () => { + await expect( + adapter.verify({ + ...fixture.submission, + submitter: Keypair.fromRawEd25519Seed(Buffer.alloc(32, 43)).publicKey(), + }), + ).rejects.toMatchObject({ code: "PAYLOAD_MISMATCH", retryable: false }); + }); + + it("defers then verifies the same submission when the ledger reaches the threshold", async () => { + const history = { + pending: jest + .fn() + .mockResolvedValue([{ id: "ledger-fixture", ...fixture.submission }]), + defer: jest.fn(), + setResult: jest.fn(), + }; + const verifier = new SubmissionVerifierService( + { recordVerification: jest.fn() } as unknown as AuditLogService, + history as unknown as SubmissionHistoryService, + adapter, + ); + try { + latest.mockResolvedValueOnce({ sequence: 100 }); + await verifier.verifyCycle(); + expect(history.defer).toHaveBeenCalledWith( + "ledger-fixture", + expect.any(String), + ); + expect(history.setResult).not.toHaveBeenCalled(); + await verifier.verifyCycle(); + expect(history.setResult).toHaveBeenCalledTimes(1); + expect(history.setResult).toHaveBeenCalledWith( + "ledger-fixture", + "verified", + ); + expect(transaction.mock.calls).toEqual([ + [fixture.submission.transactionHash], + [fixture.submission.transactionHash], + ]); + } finally { + verifier.onModuleDestroy(); + } + }); +});