From d174b6847be4ac0c32b17d554751c8d0eb82f46c Mon Sep 17 00:00:00 2001 From: "pavegroupe.dev2@gmail.com" Date: Sun, 30 Aug 2026 14:26:47 +0100 Subject: [PATCH] feat: add 192-soroban-contract-code-inspection example - Add src/examples/192-soroban-contract-code-inspection.ts - Read-only inspection of Soroban contract instance and code ledger entries - Validates contract ID format before any RPC calls - Retrieves contract instance entry and extracts code hash - Retrieves contract-code entry with last-modified and TTL metadata - Optionally hashes a supplied WASM file and compares against deployed hash - Reports match / mismatch / unable_to_verify / not_supplied - Preserves raw ledger-entry XDR for both instance and code entries - Handles missing instance, missing code, built-in executables, and RPC failures - Supports JSON output via param or JSON_OUTPUT env var - Add tests/soroban-contract-code-inspection.test.ts - Unit tests for all exported helpers and core inspection logic - Covers hash comparison states, missing entries, RPC errors, XDR capture - Verifies catalog registration and README documentation - Register example in src/runner/catalog.ts with interactive params - Add exclusion entry in src/validation/validation.config.json - Update README.md catalog with entry 54 --- README.md | 1 + .../192-soroban-contract-code-inspection.ts | 296 +++++++++++++++ src/runner/catalog.ts | 23 ++ src/validation/validation.config.json | 4 + .../soroban-contract-code-inspection.test.ts | 357 ++++++++++++++++++ 5 files changed, 681 insertions(+) create mode 100644 src/examples/192-soroban-contract-code-inspection.ts create mode 100644 tests/soroban-contract-code-inspection.test.ts diff --git a/README.md b/README.md index 4ceaa63..2a5a924 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ The repository currently includes the following runnable examples: 51. **`54-fee-stats`**: Inspecting network fee statistics, fee percentiles, capacity usage, and recommended fee values. 52. **`57-account-reserve-calculator`**: Calculating account minimum reserve requirements and available XLM balance from ledger entry breakdowns. 53. **`58-account-relationship-discovery`**: Discovering and grouping account relationships including signers, asset issuers, sponsorships, and counterparties. +54. **`192-soroban-contract-code-inspection`**: Inspecting Soroban contract code metadata, extracting the deployed code identifier, retrieving TTL and ledger information, and optionally comparing a supplied WASM hash against the on-chain code identifier. ## Installation diff --git a/src/examples/192-soroban-contract-code-inspection.ts b/src/examples/192-soroban-contract-code-inspection.ts new file mode 100644 index 0000000..3e464d0 --- /dev/null +++ b/src/examples/192-soroban-contract-code-inspection.ts @@ -0,0 +1,296 @@ +import { createHash } from 'crypto'; +import fs from 'fs'; + +import { Contract, xdr, rpc } from '@stellar/stellar-sdk'; +import chalk from 'chalk'; + +// --------------------------------------------------------------------------- +// Public helpers (exported for tests) +// --------------------------------------------------------------------------- + +/** SHA-256 hash of a WASM buffer, returned as a hex string. */ +export function hashWasm(wasm: Buffer): string { + return createHash('sha256').update(wasm).digest('hex'); +} + +/** Validate a Soroban contract ID (56-character Stellar C… address). */ +export function isValidContractId(id: string): boolean { + if (typeof id !== 'string') return false; + // Stellar contract addresses start with 'C', are 56 chars, and are base32-encoded + return /^C[A-Z2-7]{55}$/.test(id); +} + +/** Build the LedgerKey for a ContractInstance entry. */ +export function buildContractInstanceKey(contractId: string): xdr.LedgerKey { + const contractAddress = new Contract(contractId).address().toScAddress(); + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: contractAddress, + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ); +} + +/** Build the LedgerKey for a ContractCode (WASM) entry given a hex code hash. */ +export function buildContractCodeKey(codeHashHex: string): xdr.LedgerKey { + const hashBytes = Buffer.from(codeHashHex, 'hex'); + return xdr.LedgerKey.contractCode( + new xdr.LedgerKeyContractCode({ hash: hashBytes }), + ); +} + +/** + * Extract the code hash (hex string) from a ContractInstance ledger entry. + * Returns null when the entry uses a built-in (non-WASM) executable. + */ +export function extractCodeHash(entry: rpc.Api.LedgerEntryResult): string | null { + const dataXdr = entry.val; + // val is a LedgerEntry; the data union is accessed via .data() + const ledgerEntry = dataXdr as xdr.LedgerEntry; + try { + const contractData = ledgerEntry.data().contractData(); + const val = contractData.val(); + if (val.switch() !== xdr.ScValType.scvContractInstance()) return null; + const instance = val.instance(); + const executable = instance.executable(); + if (executable.switch() === xdr.ContractExecutableType.contractExecutableWasm()) { + return executable.wasmHash().toString('hex'); + } + } catch { + // not a contract-data ledger entry shape + } + return null; +} + +// --------------------------------------------------------------------------- +// Report types +// --------------------------------------------------------------------------- + +export interface ContractCodeReport { + contractId: string; + codeHash: string | null; + currentLedger: number; + instanceLastModifiedLedger: number | null; + instanceLiveUntilLedger: number | null; + codeLastModifiedLedger: number | null; + codeLiveUntilLedger: number | null; + instanceXdr: string | null; + codeXdr: string | null; + wasmHashComparison: 'match' | 'mismatch' | 'unable_to_verify' | 'not_supplied'; + error: string | null; +} + +// --------------------------------------------------------------------------- +// Core inspection logic +// --------------------------------------------------------------------------- + +export async function inspectContractCode( + server: rpc.Server, + contractId: string, + expectedHashHex?: string, +): Promise { + const report: ContractCodeReport = { + contractId, + codeHash: null, + currentLedger: 0, + instanceLastModifiedLedger: null, + instanceLiveUntilLedger: null, + codeLastModifiedLedger: null, + codeLiveUntilLedger: null, + instanceXdr: null, + codeXdr: null, + wasmHashComparison: expectedHashHex ? 'unable_to_verify' : 'not_supplied', + error: null, + }; + + // 1. Current ledger + try { + const latest = await server.getLatestLedger(); + report.currentLedger = latest.sequence; + } catch (err: any) { + report.error = `RPC failure fetching latest ledger: ${err.message}`; + return report; + } + + // 2. Contract instance ledger entry + const instanceKey = buildContractInstanceKey(contractId); + let instanceEntry: rpc.Api.LedgerEntryResult | null = null; + + try { + const instanceResult = await server.getLedgerEntries(instanceKey); + if (!instanceResult.entries || instanceResult.entries.length === 0) { + report.error = `Contract instance not found: ${contractId}`; + return report; + } + instanceEntry = instanceResult.entries[0]; + } catch (err: any) { + report.error = `RPC failure fetching contract instance: ${err.message}`; + return report; + } + + report.instanceLastModifiedLedger = instanceEntry.lastModifiedLedgerSeq ?? null; + report.instanceLiveUntilLedger = (instanceEntry as any).liveUntilLedgerSeq ?? null; + report.instanceXdr = (instanceEntry.val as xdr.LedgerEntry).toXDR('base64'); + + // 3. Extract code hash from the instance + const codeHash = extractCodeHash(instanceEntry); + report.codeHash = codeHash; + + if (codeHash === null) { + // Likely a built-in / native contract — no separate code entry + report.error = 'Contract uses a built-in executable; no separate code entry available.'; + return report; + } + + // 4. Contract code ledger entry + const codeKey = buildContractCodeKey(codeHash); + try { + const codeResult = await server.getLedgerEntries(codeKey); + if (codeResult.entries && codeResult.entries.length > 0) { + const codeEntry = codeResult.entries[0]; + report.codeLastModifiedLedger = codeEntry.lastModifiedLedgerSeq ?? null; + report.codeLiveUntilLedger = (codeEntry as any).liveUntilLedgerSeq ?? null; + report.codeXdr = (codeEntry.val as xdr.LedgerEntry).toXDR('base64'); + } + } catch (err: any) { + // Code entry missing is not fatal — report it but continue + report.error = `RPC failure fetching contract code entry: ${err.message}`; + } + + // 5. Hash comparison + if (expectedHashHex) { + const normalised = expectedHashHex.toLowerCase().replace(/^0x/, ''); + report.wasmHashComparison = normalised === codeHash ? 'match' : 'mismatch'; + } + + return report; +} + +// --------------------------------------------------------------------------- +// Output helpers +// --------------------------------------------------------------------------- + +function printReport(report: ContractCodeReport, jsonOutput: boolean): void { + if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + console.log(chalk.bold('\n=== Soroban Contract Code Inspection Report ===')); + console.log(`${chalk.bold('Contract ID:')} ${report.contractId}`); + console.log(`${chalk.bold('Current Ledger:')} ${report.currentLedger}`); + + if (report.error && !report.codeHash) { + console.log(chalk.red(`\nError: ${report.error}`)); + return; + } + + console.log(`${chalk.bold('Code Hash:')} ${report.codeHash ?? chalk.gray('n/a')}`); + + console.log(chalk.bold('\n--- Instance Entry ---')); + console.log( + `Last Modified Ledger: ${report.instanceLastModifiedLedger ?? chalk.gray('n/a')}`, + ); + console.log( + `Live Until Ledger: ${report.instanceLiveUntilLedger ?? chalk.gray('n/a')}`, + ); + console.log( + `Instance XDR: ${report.instanceXdr ? chalk.gray(report.instanceXdr.slice(0, 60) + '…') : chalk.gray('n/a')}`, + ); + + console.log(chalk.bold('\n--- Code Entry ---')); + if (report.codeLastModifiedLedger !== null) { + console.log(`Last Modified Ledger: ${report.codeLastModifiedLedger}`); + console.log(`Live Until Ledger: ${report.codeLiveUntilLedger ?? chalk.gray('n/a')}`); + console.log( + `Code XDR: ${report.codeXdr ? chalk.gray(report.codeXdr.slice(0, 60) + '…') : chalk.gray('n/a')}`, + ); + } else { + console.log(chalk.gray(' Code entry not available or not retrieved.')); + if (report.error) console.log(chalk.yellow(` Note: ${report.error}`)); + } + + console.log(chalk.bold('\n--- Hash Verification ---')); + switch (report.wasmHashComparison) { + case 'match': + console.log(chalk.green('✓ Supplied hash MATCHES deployed code identifier')); + break; + case 'mismatch': + console.log(chalk.red('✗ Supplied hash DOES NOT MATCH deployed code identifier')); + break; + case 'unable_to_verify': + console.log(chalk.yellow('⚠ Unable to verify — code entry could not be retrieved')); + break; + case 'not_supplied': + console.log(chalk.gray(' No expected hash supplied; skipping comparison')); + break; + } + + console.log(''); +} + +// --------------------------------------------------------------------------- +// Entrypoint +// --------------------------------------------------------------------------- + +export async function run(params?: { + rpcUrl?: string; + contractId?: string; + expectedHash?: string; + wasmFile?: string; + json?: boolean; +}): Promise { + const rpcUrl = + params?.rpcUrl ?? process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; + const jsonOutput = params?.json === true || process.env.JSON_OUTPUT === 'true'; + + // Resolve contract ID from param, env, or fall back to a well-known testnet contract + const contractId = + params?.contractId ?? + process.env.CONTRACT_ID ?? + 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'; + + if (!jsonOutput) { + console.log(chalk.blue(`Soroban Contract Code Inspection`)); + console.log(chalk.gray(`RPC: ${rpcUrl}`)); + } + + // Validate contract ID + if (!isValidContractId(contractId)) { + const msg = `Invalid contract ID: "${contractId}". Expected a 56-character Stellar C-address.`; + if (jsonOutput) { + console.log(JSON.stringify({ error: msg })); + } else { + console.error(chalk.red(msg)); + } + return; + } + + // Resolve optional expected hash — can come from a WASM file or direct hex + let expectedHashHex: string | undefined = params?.expectedHash ?? process.env.EXPECTED_HASH; + + const wasmFile = params?.wasmFile ?? process.env.WASM_FILE; + if (wasmFile) { + try { + const wasmBuf = fs.readFileSync(wasmFile); + expectedHashHex = hashWasm(wasmBuf); + if (!jsonOutput) { + console.log(chalk.gray(`Computed WASM hash from file: ${expectedHashHex}`)); + } + } catch (err: any) { + const msg = `Invalid WASM file "${wasmFile}": ${err.message}`; + if (jsonOutput) { + console.log(JSON.stringify({ error: msg })); + } else { + console.error(chalk.red(msg)); + } + return; + } + } + + const server = new rpc.Server(rpcUrl); + const report = await inspectContractCode(server, contractId, expectedHashHex); + + printReport(report, jsonOutput); +} diff --git a/src/runner/catalog.ts b/src/runner/catalog.ts index 2143231..de78b43 100644 --- a/src/runner/catalog.ts +++ b/src/runner/catalog.ts @@ -373,4 +373,27 @@ export const examples: Record = { }, ], }, + '192-soroban-contract-code-inspection': { + name: '192-soroban-contract-code-inspection', + description: + 'Inspect Soroban contract code metadata, extract the code identifier, and verify a supplied WASM hash', + run: loadExample('../examples/192-soroban-contract-code-inspection'), + params: [ + { + type: 'input', + name: 'contractId', + message: 'Contract ID to inspect (blank uses default testnet contract):', + }, + { + type: 'input', + name: 'expectedHash', + message: 'Optional expected code hash (hex) for verification:', + }, + { + type: 'input', + name: 'wasmFile', + message: 'Optional path to WASM file to hash and compare:', + }, + ], + }, }; diff --git a/src/validation/validation.config.json b/src/validation/validation.config.json index 7b073f0..de1bb41 100644 --- a/src/validation/validation.config.json +++ b/src/validation/validation.config.json @@ -117,6 +117,10 @@ { "match": "58-account-relationship-discovery", "reason": "Requires live Horizon account and operation data" + }, + { + "match": "192-soroban-contract-code-inspection", + "reason": "Requires Soroban RPC availability and a deployed contract instance" } ] } diff --git a/tests/soroban-contract-code-inspection.test.ts b/tests/soroban-contract-code-inspection.test.ts new file mode 100644 index 0000000..9d470c3 --- /dev/null +++ b/tests/soroban-contract-code-inspection.test.ts @@ -0,0 +1,357 @@ +import { createHash } from 'crypto'; + +import { xdr } from '@stellar/stellar-sdk'; + +import { + hashWasm, + isValidContractId, + buildContractInstanceKey, + buildContractCodeKey, + extractCodeHash, + inspectContractCode, + ContractCodeReport, +} from '../src/examples/192-soroban-contract-code-inspection'; + +// --------------------------------------------------------------------------- +// hashWasm +// --------------------------------------------------------------------------- +describe('hashWasm', () => { + it('returns a 64-char hex string', () => { + const result = hashWasm(Buffer.from('hello')); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]+$/); + }); + + it('matches a manual SHA-256', () => { + const wasm = Buffer.from('test-wasm-bytes'); + const expected = createHash('sha256').update(wasm).digest('hex'); + expect(hashWasm(wasm)).toBe(expected); + }); + + it('is deterministic', () => { + const wasm = Buffer.from('deterministic'); + expect(hashWasm(wasm)).toBe(hashWasm(wasm)); + }); + + it('returns different hashes for different inputs', () => { + expect(hashWasm(Buffer.from('a'))).not.toBe(hashWasm(Buffer.from('b'))); + }); +}); + +// --------------------------------------------------------------------------- +// isValidContractId +// --------------------------------------------------------------------------- +describe('isValidContractId', () => { + const VALID_ID = 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'; + + it('accepts a valid 56-char Stellar contract address', () => { + expect(isValidContractId(VALID_ID)).toBe(true); + }); + + it('rejects an address that is too short', () => { + expect(isValidContractId('CDW6BR4A6MGGCW23SCAVBBBZ3')).toBe(false); + }); + + it('rejects an address that starts with G (account)', () => { + const accountId = 'G' + VALID_ID.slice(1); + expect(isValidContractId(accountId)).toBe(false); + }); + + it('rejects an empty string', () => { + expect(isValidContractId('')).toBe(false); + }); + + it('rejects a non-string', () => { + expect(isValidContractId(null as any)).toBe(false); + }); + + it('rejects a string with lowercase letters', () => { + expect(isValidContractId(VALID_ID.toLowerCase())).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// buildContractInstanceKey +// --------------------------------------------------------------------------- +describe('buildContractInstanceKey', () => { + const CONTRACT_ID = 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'; + + it('returns an xdr.LedgerKey of type contractData', () => { + const key = buildContractInstanceKey(CONTRACT_ID); + expect(key.switch()).toBe(xdr.LedgerEntryType.contractData()); + }); + + it('uses persistent durability', () => { + const key = buildContractInstanceKey(CONTRACT_ID); + const data = key.contractData(); + expect(data.durability()).toBe(xdr.ContractDataDurability.persistent()); + }); + + it('uses scvLedgerKeyContractInstance as the data key', () => { + const key = buildContractInstanceKey(CONTRACT_ID); + const data = key.contractData(); + expect(data.key().switch()).toBe(xdr.ScValType.scvLedgerKeyContractInstance()); + }); +}); + +// --------------------------------------------------------------------------- +// buildContractCodeKey +// --------------------------------------------------------------------------- +describe('buildContractCodeKey', () => { + const HASH_HEX = 'a'.repeat(64); // 32-byte fake hash in hex + + it('returns an xdr.LedgerKey of type contractCode', () => { + const key = buildContractCodeKey(HASH_HEX); + expect(key.switch()).toBe(xdr.LedgerEntryType.contractCode()); + }); + + it('sets the hash bytes correctly', () => { + const key = buildContractCodeKey(HASH_HEX); + const hash = key.contractCode().hash(); + expect(hash.toString('hex')).toBe(HASH_HEX); + }); +}); + +// --------------------------------------------------------------------------- +// extractCodeHash +// --------------------------------------------------------------------------- +describe('extractCodeHash', () => { + it('returns null for a non-contract-data entry shape', () => { + // Provide a minimal mock that will throw inside extractCodeHash + const mockEntry = { + val: { data: () => { throw new Error('not contract data'); } }, + } as any; + expect(extractCodeHash(mockEntry)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// inspectContractCode — unit tests with mocked RPC server +// --------------------------------------------------------------------------- +describe('inspectContractCode', () => { + const CONTRACT_ID = 'CDW6BR4A6MGGCW23SCAVBBBZ3HW4V5C3TJ35OC3D4RQ4A6MGGCW23SCA'; + const FAKE_HASH = 'ab'.repeat(32); // 64-char hex = 32 bytes + + function makeServer(overrides: Partial<{ + getLatestLedger: () => Promise; + getLedgerEntries: (...args: any[]) => Promise; + }> = {}): any { + return { + getLatestLedger: overrides.getLatestLedger ?? (async () => ({ sequence: 1000 })), + getLedgerEntries: overrides.getLedgerEntries ?? (async () => ({ entries: [] })), + }; + } + + // Helper: build a minimal fake ContractInstance ledger entry with a WASM executable + function makeFakeInstanceEntry(codeHashHex: string): any { + const hashBytes = Buffer.from(codeHashHex, 'hex'); + const executable = xdr.ContractExecutable.contractExecutableWasm(hashBytes); + const instance = new xdr.ScContractInstance({ + executable, + storage: null, + }); + const scVal = xdr.ScVal.scvContractInstance(instance); + const contractDataEntry = new xdr.ContractDataEntry({ + ext: xdr.ExtensionPoint.v0(), + contract: xdr.ScAddress.scAddressTypeContract(Buffer.alloc(32)), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + val: scVal, + }); + const ledgerEntryData = xdr.LedgerEntryData.contractData(contractDataEntry); + const ledgerEntry = new xdr.LedgerEntry({ + lastModifiedLedgerSeq: 900, + data: ledgerEntryData, + ext: xdr.LedgerEntryExt.v0(), + }); + return { + val: ledgerEntry, + lastModifiedLedgerSeq: 900, + liveUntilLedgerSeq: 2000, + }; + } + + it('reports error when latest ledger fetch fails', async () => { + const server = makeServer({ + getLatestLedger: async () => { throw new Error('network error'); }, + }); + const report = await inspectContractCode(server, CONTRACT_ID); + expect(report.error).toMatch(/RPC failure fetching latest ledger/); + expect(report.currentLedger).toBe(0); + }); + + it('reports error when contract instance is missing', async () => { + const server = makeServer({ + getLedgerEntries: async () => ({ entries: [] }), + }); + const report = await inspectContractCode(server, CONTRACT_ID); + expect(report.error).toMatch(/not found/i); + expect(report.codeHash).toBeNull(); + }); + + it('reports error when getLedgerEntries throws for instance', async () => { + const server = makeServer({ + getLedgerEntries: async () => { throw new Error('rpc down'); }, + }); + const report = await inspectContractCode(server, CONTRACT_ID); + expect(report.error).toMatch(/RPC failure fetching contract instance/); + }); + + it('extracts code hash from a valid instance entry', async () => { + const instanceEntry = makeFakeInstanceEntry(FAKE_HASH); + let callCount = 0; + const server = makeServer({ + getLedgerEntries: async () => { + callCount++; + if (callCount === 1) return { entries: [instanceEntry] }; + return { entries: [] }; // code entry not found + }, + }); + const report = await inspectContractCode(server, CONTRACT_ID); + expect(report.codeHash).toBe(FAKE_HASH); + expect(report.instanceLastModifiedLedger).toBe(900); + expect(report.instanceLiveUntilLedger).toBe(2000); + }); + + it('sets wasmHashComparison to match when hashes agree', async () => { + const instanceEntry = makeFakeInstanceEntry(FAKE_HASH); + let callCount = 0; + const server = makeServer({ + getLedgerEntries: async () => { + callCount++; + if (callCount === 1) return { entries: [instanceEntry] }; + return { entries: [] }; + }, + }); + const report = await inspectContractCode(server, CONTRACT_ID, FAKE_HASH); + expect(report.wasmHashComparison).toBe('match'); + }); + + it('sets wasmHashComparison to mismatch when hashes differ', async () => { + const instanceEntry = makeFakeInstanceEntry(FAKE_HASH); + let callCount = 0; + const server = makeServer({ + getLedgerEntries: async () => { + callCount++; + if (callCount === 1) return { entries: [instanceEntry] }; + return { entries: [] }; + }, + }); + const differentHash = 'cd'.repeat(32); + const report = await inspectContractCode(server, CONTRACT_ID, differentHash); + expect(report.wasmHashComparison).toBe('mismatch'); + }); + + it('sets wasmHashComparison to not_supplied when no expected hash is given', async () => { + const instanceEntry = makeFakeInstanceEntry(FAKE_HASH); + let callCount = 0; + const server = makeServer({ + getLedgerEntries: async () => { + callCount++; + if (callCount === 1) return { entries: [instanceEntry] }; + return { entries: [] }; + }, + }); + const report = await inspectContractCode(server, CONTRACT_ID); + expect(report.wasmHashComparison).toBe('not_supplied'); + }); + + it('sets wasmHashComparison to unable_to_verify when code entry lookup fails', async () => { + const instanceEntry = makeFakeInstanceEntry(FAKE_HASH); + let callCount = 0; + const server = makeServer({ + getLedgerEntries: async () => { + callCount++; + if (callCount === 1) return { entries: [instanceEntry] }; + throw new Error('code lookup failed'); + }, + }); + const report = await inspectContractCode(server, CONTRACT_ID, FAKE_HASH); + expect(report.wasmHashComparison).toBe('unable_to_verify'); + expect(report.error).toMatch(/RPC failure fetching contract code entry/); + }); + + it('captures code entry metadata when available', async () => { + const instanceEntry = makeFakeInstanceEntry(FAKE_HASH); + const codeEntry = { + val: new xdr.LedgerEntry({ + lastModifiedLedgerSeq: 800, + data: xdr.LedgerEntryData.contractCode( + new xdr.ContractCodeEntry({ + ext: xdr.ContractCodeEntryExt.v0(), + hash: Buffer.from(FAKE_HASH, 'hex'), + code: Buffer.from('wasm-bytecode'), + }), + ), + ext: xdr.LedgerEntryExt.v0(), + }), + lastModifiedLedgerSeq: 800, + liveUntilLedgerSeq: 3000, + }; + let callCount = 0; + const server = makeServer({ + getLedgerEntries: async () => { + callCount++; + if (callCount === 1) return { entries: [instanceEntry] }; + return { entries: [codeEntry] }; + }, + }); + const report = await inspectContractCode(server, CONTRACT_ID); + expect(report.codeLastModifiedLedger).toBe(800); + expect(report.codeLiveUntilLedger).toBe(3000); + expect(report.codeXdr).toBeTruthy(); + }); + + it('stores raw XDR for the instance entry', async () => { + const instanceEntry = makeFakeInstanceEntry(FAKE_HASH); + let callCount = 0; + const server = makeServer({ + getLedgerEntries: async () => { + callCount++; + if (callCount === 1) return { entries: [instanceEntry] }; + return { entries: [] }; + }, + }); + const report = await inspectContractCode(server, CONTRACT_ID); + expect(report.instanceXdr).toBeTruthy(); + expect(typeof report.instanceXdr).toBe('string'); + }); + + it('handles 0x-prefixed expected hash', async () => { + const instanceEntry = makeFakeInstanceEntry(FAKE_HASH); + let callCount = 0; + const server = makeServer({ + getLedgerEntries: async () => { + callCount++; + if (callCount === 1) return { entries: [instanceEntry] }; + return { entries: [] }; + }, + }); + const report = await inspectContractCode(server, CONTRACT_ID, '0x' + FAKE_HASH); + expect(report.wasmHashComparison).toBe('match'); + }); +}); + +// --------------------------------------------------------------------------- +// Runner registration +// --------------------------------------------------------------------------- +describe('runner catalog registration', () => { + it('registers 192-soroban-contract-code-inspection in the catalog', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { examples } = require('../src/runner/catalog'); + expect(examples['192-soroban-contract-code-inspection']).toBeDefined(); + expect(typeof examples['192-soroban-contract-code-inspection'].run).toBe('function'); + expect(examples['192-soroban-contract-code-inspection'].description).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// README documentation +// --------------------------------------------------------------------------- +describe('README catalog entry', () => { + it('documents 192-soroban-contract-code-inspection in README.md', () => { + const fs = require('fs'); + const readme = fs.readFileSync('README.md', 'utf8'); + expect(readme).toContain('192-soroban-contract-code-inspection'); + }); +});