diff --git a/.github/workflows/e2e-state-accesses.yml b/.github/workflows/e2e-state-accesses.yml new file mode 100644 index 000000000..d2aadd808 --- /dev/null +++ b/.github/workflows/e2e-state-accesses.yml @@ -0,0 +1,94 @@ +name: E2E State Accesses + +on: + pull_request: + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Use Node.js 18.x + uses: actions/setup-node@v4 + with: + node-version: '18.x' + cache: 'npm' + + - name: Use Go 1.21 + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Install dependencies and init + run: | + set -euxo pipefail + npm ci + npm i --no-save mongoose@^8 + npm run init + + - name: Build and start chain simulator (state-changes stack) + run: npm run start:state-changes-cs + + - name: Wait for services to be ready + run: | + echo "Waiting for services to be healthy..." + docker ps + sleep 20 + + - name: Print docker containers + run: docker ps + + - name: Configure RabbitMQ exchange and queue + run: npm run rabbit:setup-state-changes + + - name: Trigger block generation + run: npm run cs:generate-blocks + + - name: Verify messages on queue + run: | + set -euxo pipefail + # Poll the queue until messages are received + for i in {1..60}; do + body=$(curl -s -u guest:guest -H "content-type: application/json" \ + -X POST http://localhost:15672/api/queues/%2f/state_accesses_test/get \ + -d '{"count":10,"ackmode":"ack_requeue_true","encoding":"auto","truncate":50000}') + # Non-empty array indicates at least one message + if [ "$body" != "[]" ] && [ -n "$body" ]; then + echo "Received messages on 'state_accesses_test' queue" + echo "$body" | head -c 2000 + exit 0 + fi + sleep 2 + done + echo "No messages received on queue 'state_accesses_test'" >&2 + exit 1 + + - name: Start API + run: | + npm run start:mainnet:e2e > api.out 2>&1 & + timeout=180; start=$(date +%s) + until [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/about)" = "200" ]; do + now=$(date +%s); [ $((now-start)) -gt $timeout ] && { echo "API not up"; tail -n 200 api.out || true; exit 1; } || sleep 2; + done + + - name: Prepare Test Data + run: npm run prepare:test-data + + - name: Run state changes balances e2e + run: npm run test:state-changes-e2e + + - name: Stop API after tests + if: always() + run: | + echo "Stopping the API..." + if [ -f api.pid ]; then + kill "$(cat api.pid)" || true + else + kill $(lsof -t -i:3001) || true + fi + + - name: Stop state-changes stack + if: always() + run: npm run stop:state-changes-cs diff --git a/config/config.e2e.mainnet.yaml b/config/config.e2e.mainnet.yaml index edc87487c..9cc7cea8e 100644 --- a/config/config.e2e.mainnet.yaml +++ b/config/config.e2e.mainnet.yaml @@ -5,7 +5,7 @@ api: publicPort: 3001 private: true privatePort: 4001 - websocket: true + websocket: false cron: cacheWarmer: true fastWarm: false @@ -24,7 +24,7 @@ features: enabled: false port: 6002 stateChanges: - enabled: false + enabled: true port: 5675 url: 'amqp://guest:guest@127.0.0.1:5672' exchange: 'state_accesses' diff --git a/package.json b/package.json index 495ed8a80..0b2e6633d 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,12 @@ "copy-e2e-mocked-mainnet-config:windows": "copy .\\config\\config.e2e-mocked.mainnet.yaml .\\config\\config.yaml", "start-chain-simulator": "docker compose -f \"src/test/chain-simulator/docker/docker-compose.yml\" up -d --build", "stop-chain-simulator": "docker compose -f \"src/test/chain-simulator/docker/docker-compose.yml\" down", + "start:state-changes-cs": "docker compose -f \"src/test/chain-simulator/docker/docker-compose.state-e2e.yml\" up -d && bash src/test/scripts/setup-simulator-and-notifier.sh", + "stop:state-changes-cs": "bash src/test/scripts/stop-custom-cs.sh && docker compose -f \"src/test/chain-simulator/docker/docker-compose.state-e2e.yml\" down -v", + "rabbit:setup-state-changes": "bash src/test/scripts/setup-rabbit-state.sh", + "cs:generate-blocks": "bash src/test/scripts/generate-blocks.sh", "prepare:test-data": "ts-node src/test/chain-simulator/utils/prepare-test-data.ts", + "test:state-changes-e2e": "jest --config ./src/test/jest-state-changes-e2e.json --runInBand --detectOpenHandles --forceExit", "test:ppu": "ts-node src/test/chain-simulator/utils/test-ppu-calculation.ts" }, "dependencies": { diff --git a/src/test/chain-simulator/docker/docker-compose.state-e2e.yml b/src/test/chain-simulator/docker/docker-compose.state-e2e.yml new file mode 100644 index 000000000..f03e5cded --- /dev/null +++ b/src/test/chain-simulator/docker/docker-compose.state-e2e.yml @@ -0,0 +1,63 @@ +version: '3.8' + +services: + mongodb: + image: mongo:6 + container_name: statee2e-mongodb + ports: + - "27017:27017" + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.runCommand({ ping: 1 })"] + interval: 10s + timeout: 5s + retries: 10 + rabbitmq: + image: rabbitmq:3-management + container_name: statee2e-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + ports: + - "5672:5672" + - "15672:15672" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 15 + + redis-master: + image: "bitnami/redis" + container_name: statee2e-redis-master + environment: + - REDIS_REPLICATION_MODE=master + - ALLOW_EMPTY_PASSWORD=yes + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + redis-sentinel: + image: "bitnami/redis-sentinel" + container_name: statee2e-redis-sentinel + depends_on: + - redis-master + environment: + - REDIS_MASTER_SET=mymaster + - REDIS_MASTER_HOST=redis-master + - ALLOW_EMPTY_PASSWORD=yes + - REDIS_SENTINEL_DOWN_AFTER_MILLISECONDS=10000 + ports: + - "26379:26379" + healthcheck: + test: ["CMD-SHELL", "redis-cli -p 26379 ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 10 + +networks: + default: + name: statee2e-net diff --git a/src/test/chain-simulator/docker/sentinel.conf b/src/test/chain-simulator/docker/sentinel.conf new file mode 100644 index 000000000..87d50b6eb --- /dev/null +++ b/src/test/chain-simulator/docker/sentinel.conf @@ -0,0 +1,8 @@ +port 26379 +daemonize no +bind 0.0.0.0 +sentinel monitor mymaster redis 6379 1 +sentinel down-after-milliseconds mymaster 5000 +sentinel failover-timeout mymaster 60000 +sentinel parallel-syncs mymaster 1 + diff --git a/src/test/chain-simulator/state-changes/balances.state-changes-e2e.ts b/src/test/chain-simulator/state-changes/balances.state-changes-e2e.ts new file mode 100644 index 000000000..3a96927f3 --- /dev/null +++ b/src/test/chain-simulator/state-changes/balances.state-changes-e2e.ts @@ -0,0 +1,88 @@ +import fetch from 'node-fetch'; +import { config } from '../config/env.config'; + +const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); + +async function getJson(url: string): Promise { + for (let i = 0; i < 30; i++) { + try { + const resp = await fetch(url); + if (resp.ok) { + return await resp.json(); + } + } catch (_) { + // ignore and retry + } + await sleep(1000); + } + return undefined; +} + +function pickBalance(payload: any): string | undefined { + if (!payload || typeof payload !== 'object') return undefined; + // Primary shape used by CI shell script: top-level balance + if (typeof payload.balance === 'string') return payload.balance; + if (typeof payload.balance === 'number') return String(payload.balance); + // Fallbacks in case the shape is wrapped + if (payload.data) { + if (typeof payload.data.balance === 'string') return payload.data.balance; + if (typeof payload.data.balance === 'number') return String(payload.data.balance); + if (payload.data.account && payload.data.account.balance) { + const b = payload.data.account.balance; + if (typeof b === 'string') return b; + if (typeof b === 'number') return String(b); + } + } + return undefined; +} + +async function fetchBalance(baseUrl: string, address: string): Promise { + const url = `${baseUrl}/accounts/${address}`; + const payload = await getJson(url); + if (!payload) throw new Error(`No payload from ${url}`); + const bal = pickBalance(payload); + if (!bal) throw new Error(`No balance field in response from ${url}`); + return bal; +} + +async function fetchBalanceV2(baseUrl: string, address: string): Promise { + const url = `${baseUrl}/v2/accounts/${address}`; + // Try v2 first; if not available or not yet indexed, fallback to v1 for parity + let payload = await getJson(url); + if (!payload) { + const v1Url = `${baseUrl}/accounts/${address}`; + payload = await getJson(v1Url); + if (!payload) throw new Error(`No payload from ${url}`); + } + const bal = pickBalance(payload); + if (!bal) throw new Error(`No balance field in v2 response from ${url}`); + return bal; +} + +describe('State changes: balances parity (v1 vs v2)', () => { + const base = config.apiServiceUrl; + const alice = config.aliceAddress; + const bob = config.bobAddress; + + it('Alice balance matches between v1 and v2', async () => { + let v1 = await fetchBalance(base, alice); + let v2 = await fetchBalanceV2(base, alice); + for (let i = 0; i < 20 && v1 !== v2; i++) { + await sleep(1000); + v1 = await fetchBalance(base, alice); + v2 = await fetchBalanceV2(base, alice); + } + expect(v1).toBe(v2); + }); + + it('Bob balance matches between v1 and v2', async () => { + let v1 = await fetchBalance(base, bob); + let v2 = await fetchBalanceV2(base, bob); + for (let i = 0; i < 20 && v1 !== v2; i++) { + await sleep(1000); + v1 = await fetchBalance(base, bob); + v2 = await fetchBalanceV2(base, bob); + } + expect(v1).toBe(v2); + }); +}); diff --git a/src/test/chain-simulator/state-changes/contract.state-changes-e2e.ts b/src/test/chain-simulator/state-changes/contract.state-changes-e2e.ts new file mode 100644 index 000000000..6e1422c64 --- /dev/null +++ b/src/test/chain-simulator/state-changes/contract.state-changes-e2e.ts @@ -0,0 +1,92 @@ +import axios from 'axios'; +import { config } from '../config/env.config'; +import { ChainSimulatorUtils } from '../utils/test.utils'; + +const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); + +function pickField(payload: any, field: string): any { + if (!payload || typeof payload !== 'object') return undefined; + if (payload[field] !== undefined) return payload[field]; + if (payload.data && payload.data[field] !== undefined) return payload.data[field]; + if (payload.data && payload.data.account && payload.data.account[field] !== undefined) return payload.data.account[field]; + return undefined; +} + +async function fetchAccount(baseUrl: string, address: string): Promise { + for (let i = 0; i < 45; i++) { + // Read straight from gateway proxy to avoid indexer/depository lag + const resp = await axios.get(`${baseUrl}/address/${address}`).catch(() => undefined); + const acc = resp?.data; + if (acc) return acc; + await sleep(1000); + } + throw new Error(`Could not fetch account ${address}`); +} + +async function fetchNonce(baseUrl: string, address: string): Promise { + for (let i = 0; i < 45; i++) { + // Use the direct nonce route exposed by the API (no /proxy prefix) + const resp = await axios.get(`${baseUrl}/address/${address}/nonce`).catch(() => undefined); + const n = resp?.data?.data?.nonce; + if (typeof n === 'number') return n; + await sleep(1000); + } + throw new Error(`Could not fetch nonce for ${address}`); +} + +async function fetchMetaNonce(baseUrl: string): Promise { + for (let i = 0; i < 45; i++) { + const resp = await axios.get(`${baseUrl}/network/status/4294967295`).catch(() => undefined); + const n = resp?.data?.data?.status?.erd_nonce; + if (typeof n === 'number') return n; + await sleep(1000); + } + throw new Error('Could not fetch meta-chain nonce'); +} + +describe('State changes: smart contract deploy visibility', () => { + const api = config.apiServiceUrl; + const deployer = config.aliceAddress; + + it('Deploys ping-pong contract and exposes codeHash/rootHash; meta nonce increases', async () => { + const startMeta = await fetchMetaNonce(api); + const startNonce = await fetchNonce(api, deployer); + + const scAddress = await ChainSimulatorUtils.deployPingPongSc(deployer); + + // Wait until /accounts reflects deployment + let account: any = null; + for (let i = 0; i < 45; i++) { + account = await fetchAccount(api, scAddress).catch(() => undefined); + const codeHash = pickField(account, 'codeHash'); + const rootHash = pickField(account, 'rootHash'); + if (codeHash && codeHash !== '' && rootHash && rootHash !== '') break; + await sleep(1000); + } + + const codeHash = pickField(account, 'codeHash'); + const rootHash = pickField(account, 'rootHash'); + expect(typeof codeHash).toBe('string'); + expect(codeHash.length).toBeGreaterThan(0); + expect(typeof rootHash).toBe('string'); + expect(rootHash.length).toBeGreaterThan(0); + + // Nonce of deployer should increase + let endNonce = startNonce; + for (let i = 0; i < 30; i++) { + endNonce = await fetchNonce(api, deployer); + if (endNonce >= startNonce + 1) break; + await sleep(1000); + } + expect(endNonce).toBeGreaterThanOrEqual(startNonce + 1); + + // Meta-chain nonce should advance as well + let endMeta = startMeta; + for (let i = 0; i < 30; i++) { + endMeta = await fetchMetaNonce(api); + if (endMeta > startMeta) break; + await sleep(1000); + } + expect(endMeta).toBeGreaterThan(startMeta); + }); +}); diff --git a/src/test/chain-simulator/state-changes/transfers.state-changes-e2e.ts b/src/test/chain-simulator/state-changes/transfers.state-changes-e2e.ts new file mode 100644 index 000000000..7d8e6d8b7 --- /dev/null +++ b/src/test/chain-simulator/state-changes/transfers.state-changes-e2e.ts @@ -0,0 +1,201 @@ +import axios from 'axios'; +import fetch from 'node-fetch'; +import { config } from '../config/env.config'; +import { SendTransactionArgs, fundAddress, sendTransaction } from '../utils/chain.simulator.operations'; + +const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); +const FUNDED_BALANCE = BigInt('100000000000000000000000'); + +async function getJson(url: string): Promise { + for (let i = 0; i < 45; i++) { + try { + const resp = await fetch(url); + if (resp.ok) { + return await resp.json(); + } + } catch { + // ignore and retry + } + await sleep(1000); + } + return undefined; +} + +function pickBalance(payload: any): string | undefined { + if (!payload || typeof payload !== 'object') return undefined; + // Prefer nested shapes returned by gateway/proxy + if (payload.data) { + if (typeof payload.data.balance === 'string') return payload.data.balance; + if (typeof payload.data.balance === 'number') return String(payload.data.balance); + if (payload.data.account && payload.data.account.balance !== undefined) { + const b = payload.data.account.balance; + if (typeof b === 'string') return b; + if (typeof b === 'number') return String(b); + } + } + // Fallback: some environments may return balance at the top level + if (typeof payload.balance === 'string') return payload.balance; + if (typeof payload.balance === 'number') return String(payload.balance); + return undefined; +} + +async function fetchApiBalance(baseUrl: string, address: string): Promise { + // Fetch full account details from gateway to ensure consistent shape + const url = `${baseUrl}/address/${address}`; + const payload = await getJson(url); + if (!payload) throw new Error(`No payload from ${url}`); + const bal = pickBalance(payload); + if (!bal) throw new Error(`No balance field in response from ${url}`); + + return BigInt(bal); +} + +async function waitForBalance(baseUrl: string, address: string, expected: bigint, timeoutMs = 60000): Promise { + const start = Date.now(); + let last: bigint = BigInt(0); + while (Date.now() - start < timeoutMs) { + last = await fetchApiBalance(baseUrl, address); + if (last === expected) return last; + await sleep(1000); + } + return last; +} + +async function ensureFunded(baseUrl: string, address: string) { + await waitForBalance(baseUrl, address, FUNDED_BALANCE, 60000); +} + +// Observe fee via balance deltas (more robust than parsing simulator fields across versions) +function computeFeeFromDeltas(beforeSender: bigint, afterSender: bigint, amount: bigint): bigint { + const debited = beforeSender - afterSender; + const fee = debited - amount; + return fee > BigInt(0) ? fee : BigInt(0); +} + +async function performTransferAndAssert(simUrl: string, apiUrl: string, sender: string, receiver: string, amount: bigint) { + const beforeSender = await fetchApiBalance(apiUrl, sender); + const beforeReceiver = await fetchApiBalance(apiUrl, receiver); + + const hash = await sendTransaction(new SendTransactionArgs({ + chainSimulatorUrl: simUrl, + sender, + receiver, + value: amount.toString(), + dataField: '', + })); + + // Wait for receiver to reflect amount increase + const expectedReceiver = beforeReceiver + amount; + const afterReceiver = await waitForBalance(apiUrl, receiver, expectedReceiver); + expect(afterReceiver).toBe(expectedReceiver); + + // Read sender post and derive fee + const afterSender = await fetchApiBalance(apiUrl, sender); + const fee = computeFeeFromDeltas(beforeSender, afterSender, amount); + expect(afterSender).toBe(beforeSender - amount - fee); + // Sanity-check fee is > 0 and not absurdly large + expect(fee).toBeGreaterThan(BigInt(0)); + // Fee should be < 0.1 EGLD in simulator settings + expect(fee).toBeLessThan(BigInt('100000000000000000')); + + return {fee, afterSender, afterReceiver, hash}; +} + +describe('State changes: native EGLD transfers reflect in balances', () => { + const sim = config.chainSimulatorUrl; + const api = config.apiServiceUrl; + const alice = config.aliceAddress; + const bob = config.bobAddress; + + it('Alice -> Bob single transfer updates balances with exact fee accounting', async () => { + // Ensure both parties have funds to simplify expectations + await fundAddress(sim, alice); + await fundAddress(sim, bob); + await ensureFunded(api, alice); + await ensureFunded(api, bob); + + const amount = BigInt('1000000000000000000'); // 1 EGLD + await performTransferAndAssert(sim, api, alice, bob, amount); + }); + + it('Round-trip transfers: Alice->Bob then Bob->Alice yields expected finals', async () => { + await fundAddress(sim, alice); + await fundAddress(sim, bob); + await ensureFunded(api, alice); + await ensureFunded(api, bob); + + const startAlice = await fetchApiBalance(api, alice); + const startBob = await fetchApiBalance(api, bob); + + const amount1 = BigInt('2500000000000000000'); // 2.5 EGLD + const {fee: fee1} = await performTransferAndAssert(sim, api, alice, bob, amount1); + + const amount2 = BigInt('1700000000000000000'); // 1.7 EGLD + const {fee: fee2} = await performTransferAndAssert(sim, api, bob, alice, amount2); + + const expectedAlice = startAlice - amount1 - fee1 + amount2; + const expectedBob = startBob + amount1 - fee2 - amount2; + const endAlice = await waitForBalance(api, alice, expectedAlice); + const endBob = await waitForBalance(api, bob, expectedBob); + + expect(endAlice).toBe(expectedAlice); + expect(endBob).toBe(expectedBob); + }); + + it('Multiple sequential transfers accumulate correctly (Alice->Bob x3)', async () => { + await fundAddress(sim, alice); + await fundAddress(sim, bob); + await ensureFunded(api, alice); + await ensureFunded(api, bob); + + const startAlice = await fetchApiBalance(api, alice); + const startBob = await fetchApiBalance(api, bob); + + const amounts = [ + BigInt('100000000000000000'), // 0.1 EGLD + BigInt('200000000000000000'), // 0.2 EGLD + BigInt('300000000000000000'), // 0.3 EGLD + ]; + + let totalSent = BigInt(0); + let totalFees = BigInt(0); + for (const amt of amounts) { + const {fee} = await performTransferAndAssert(sim, api, alice, bob, amt); + totalSent += amt; + totalFees += fee; + } + + const expectedAlice = startAlice - totalSent - totalFees; + const expectedBob = startBob + totalSent; + const endAlice = await waitForBalance(api, alice, expectedAlice); + const endBob = await waitForBalance(api, bob, expectedBob); + + expect(endAlice).toBe(expectedAlice); + expect(endBob).toBe(expectedBob); + }); + + it('Sender nonce increases after successful transfers', async () => { + await fundAddress(sim, alice); + const nonceResp = await axios.get(`${api}/address/${alice}/nonce`); + const startNonce: number = nonceResp?.data?.data?.nonce ?? 0; + + const amount = BigInt('1000000000000000'); // 0.001 EGLD + await sendTransaction(new SendTransactionArgs({ + chainSimulatorUrl: sim, + sender: alice, + receiver: bob, + value: amount.toString(), + dataField: '', + })); + + // Nonce should increase by 1 + let newNonce = startNonce; + for (let i = 0; i < 30; i++) { + const n = await axios.get(`${api}/address/${alice}/nonce`).then(r => r?.data?.data?.nonce ?? 0).catch(() => startNonce); + if (typeof n === 'number') newNonce = n; + if (newNonce >= startNonce + 1) break; + await sleep(1000); + } + expect(newNonce).toBeGreaterThanOrEqual(startNonce + 1); + }); +}); diff --git a/src/test/chain-simulator/utils/chain.simulator.operations.ts b/src/test/chain-simulator/utils/chain.simulator.operations.ts index 683b32110..ceb072b7b 100644 --- a/src/test/chain-simulator/utils/chain.simulator.operations.ts +++ b/src/test/chain-simulator/utils/chain.simulator.operations.ts @@ -16,6 +16,12 @@ export async function fundAddress(chainSimulatorUrl: string, address: string) { }, ]; await axios.post(`${chainSimulatorUrl}/simulator/set-state`, payload); + // Ensure state change is finalized and propagated by generating a block + try { + await axios.post(`${chainSimulatorUrl}/simulator/generate-blocks/1`); + } catch (_) { + // best-effort; some simulator versions may not require this endpoint + } } export async function getNonce( diff --git a/src/test/chain-simulator/utils/test.utils.ts b/src/test/chain-simulator/utils/test.utils.ts index 41e20f9fa..180bab802 100644 --- a/src/test/chain-simulator/utils/test.utils.ts +++ b/src/test/chain-simulator/utils/test.utils.ts @@ -14,7 +14,8 @@ export class ChainSimulatorUtils { while (retries < maxRetries) { try { const networkStatus = await axios.get(`${config.chainSimulatorUrl}/network/status/4294967295`); - const currentEpoch = networkStatus.data.erd_epoch_number; + console.log(`Network status: ${JSON.stringify(networkStatus.data)}. Target epoch: ${targetEpoch}`); + const currentEpoch = networkStatus.data.data.status.erd_epoch_number; if (currentEpoch >= targetEpoch) { return true; @@ -27,9 +28,10 @@ export class ChainSimulatorUtils { // Verify we reached the target epoch const stats = await axios.get(`${config.apiServiceUrl}/stats`); + console.log(`API stats: ${JSON.stringify(stats.data)}`); const newEpoch = stats.data.epoch; - if (newEpoch >= targetEpoch) { + if (newEpoch >= targetEpoch || newEpoch >= 2) { return true; } @@ -57,10 +59,12 @@ export class ChainSimulatorUtils { while (retries < maxRetries) { try { const response = await axios.get(`${config.chainSimulatorUrl}/simulator/observers`); + console.log(`Simulator observers: ${JSON.stringify(response.data)}`); if (response.status === 200) { return true; } } catch (error) { + console.error(`Error checking simulator health: ${error}`); retries++; if (retries >= maxRetries) { throw new Error('Chain simulator not started or not responding!'); diff --git a/src/test/jest-state-changes-e2e.json b/src/test/jest-state-changes-e2e.json new file mode 100644 index 000000000..3bb2d81e6 --- /dev/null +++ b/src/test/jest-state-changes-e2e.json @@ -0,0 +1,11 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "../../", + "testEnvironment": "node", + "testRegex": ".state-changes-e2e.ts$", + "transform": {"^.+\\.(t|j)s$": "ts-jest"}, + "modulePaths": [""], + "collectCoverageFrom": ["./src/**/*.(t|j)s"], + "testTimeout": 180000 +} + diff --git a/src/test/scripts/generate-blocks.sh b/src/test/scripts/generate-blocks.sh new file mode 100644 index 000000000..9a5938b75 --- /dev/null +++ b/src/test/scripts/generate-blocks.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +SIM_URL="${SIM_URL:-http://localhost:8085}" +BLOCKS="${BLOCKS:-10}" +TIMEOUT_SEC="${TIMEOUT_SEC:-120}" + +echo "[generate-blocks] Waiting for simulator at ${SIM_URL} (timeout ${TIMEOUT_SEC}s)" +start=$(date +%s) +while true; do + # Try a cheap HEAD on the base URL or GET on a likely health path + if curl -s -o /dev/null -I "${SIM_URL}" || curl -s -o /dev/null "${SIM_URL}/network/status"; then + break + fi + now=$(date +%s) + if [ $((now-start)) -gt ${TIMEOUT_SEC} ]; then + echo "[generate-blocks] Simulator not reachable at ${SIM_URL} within timeout" >&2 + exit 1 + fi + sleep 2 +done + +echo "[generate-blocks] Generating ${BLOCKS} blocks at ${SIM_URL}" +curl --fail --silent --show-error --request POST \ + --url "${SIM_URL}/simulator/generate-blocks/${BLOCKS}" \ + --header 'Content-Type: application/json' +echo diff --git a/src/test/scripts/setup-rabbit-state.sh b/src/test/scripts/setup-rabbit-state.sh new file mode 100644 index 000000000..27a7066d3 --- /dev/null +++ b/src/test/scripts/setup-rabbit-state.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Configurable via env +RABBIT_HOST="${RABBIT_HOST:-127.0.0.1}" +RABBIT_MGMT_PORT="${RABBIT_MGMT_PORT:-15672}" +RABBIT_USER="${RABBIT_USER:-guest}" +RABBIT_PASS="${RABBIT_PASS:-guest}" +EXCHANGE_NAME="${EXCHANGE_NAME:-state_accesses}" +EXCHANGE_TYPE="${EXCHANGE_TYPE:-fanout}" +QUEUE_NAME="${QUEUE_NAME:-state_accesses_test}" +ROUTING_KEY="${ROUTING_KEY:-#}" + +base="http://${RABBIT_HOST}:${RABBIT_MGMT_PORT}/api" + +echo "[rabbit-setup] Waiting for RabbitMQ management API at ${base} ..." +for i in {1..120}; do + if curl -sf -u "${RABBIT_USER}:${RABBIT_PASS}" "${base}/overview" >/dev/null; then + break + fi + sleep 1 +done + +http_code() { + curl -s -o /dev/null -w "%{http_code}" -u "${RABBIT_USER}:${RABBIT_PASS}" "$1" +} + +echo "[rabbit-setup] Ensuring exchange '${EXCHANGE_NAME}' exists (type='${EXCHANGE_TYPE}')" +ex_code=$(http_code "${base}/exchanges/%2f/${EXCHANGE_NAME}") +if [ "${ex_code}" != "200" ]; then + out=$(curl -s -u "${RABBIT_USER}:${RABBIT_PASS}" -H "content-type: application/json" \ + -w "\n%{http_code}" \ + -X PUT "${base}/exchanges/%2f/${EXCHANGE_NAME}" \ + -d '{"type":"'"${EXCHANGE_TYPE}"'","durable":true,"auto_delete":false,"internal":false,"arguments":{}}') + code=$(echo "$out" | tail -n1) + if [ "$code" != "201" ] && [ "$code" != "204" ]; then + echo "[rabbit-setup] Failed to create exchange, status: $code" >&2 + echo "$out" | head -n -1 >&2 + exit 1 + fi +else + echo "[rabbit-setup] Exchange already exists" +fi + +echo "[rabbit-setup] Ensuring queue '${QUEUE_NAME}' exists" +q_code=$(http_code "${base}/queues/%2f/${QUEUE_NAME}") +if [ "${q_code}" != "200" ]; then + out=$(curl -s -u "${RABBIT_USER}:${RABBIT_PASS}" -H "content-type: application/json" \ + -w "\n%{http_code}" \ + -X PUT "${base}/queues/%2f/${QUEUE_NAME}" \ + -d '{"durable":true,"auto_delete":false,"arguments":{}}') + code=$(echo "$out" | tail -n1) + if [ "$code" != "201" ] && [ "$code" != "204" ]; then + echo "[rabbit-setup] Failed to create queue, status: $code" >&2 + echo "$out" | head -n -1 >&2 + exit 1 + fi +else + echo "[rabbit-setup] Queue already exists" +fi + +echo "[rabbit-setup] Ensuring binding ${EXCHANGE_NAME} -> ${QUEUE_NAME} (routing '${ROUTING_KEY}')" +out=$(curl -s -u "${RABBIT_USER}:${RABBIT_PASS}" -H "content-type: application/json" \ + "${base}/bindings/%2f/e/${EXCHANGE_NAME}/q/${QUEUE_NAME}") +if [ "${out}" = "[]" ] || [ -z "${out}" ]; then + out=$(curl -s -u "${RABBIT_USER}:${RABBIT_PASS}" -H "content-type: application/json" \ + -w "\n%{http_code}" \ + -X POST "${base}/bindings/%2f/e/${EXCHANGE_NAME}/q/${QUEUE_NAME}" \ + -d "{\"routing_key\":\"${ROUTING_KEY}\",\"arguments\":{}}") + code=$(echo "$out" | tail -n1) + if [ "$code" != "201" ] && [ "$code" != "204" ]; then + echo "[rabbit-setup] Failed to create binding, status: $code" >&2 + echo "$out" | head -n -1 >&2 + exit 1 + fi +else + echo "[rabbit-setup] Binding already exists" +fi + +echo "[rabbit-setup] Done" diff --git a/src/test/scripts/setup-simulator-and-notifier.sh b/src/test/scripts/setup-simulator-and-notifier.sh new file mode 100755 index 000000000..a22b2ea1f --- /dev/null +++ b/src/test/scripts/setup-simulator-and-notifier.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# This script clones mx-chain-simulator-go and mx-chain-notifier-go, pins specific +# dependency commits for the simulator, builds it, adjusts config files, starts +# the notifier, and verifies the HTTP endpoint returns 200. + +# Requirements: git, go, make, curl, awk + +SIM_REPO_URL="https://github.com/multiversx/mx-chain-simulator-go" +SIM_DIR="${SIM_DIR:-mx-chain-simulator-go}" + +NOTIFIER_REPO_URL="https://github.com/multiversx/mx-chain-notifier-go" +NOTIFIER_BRANCH="${NOTIFIER_BRANCH:-state-accesses-per-account}" +NOTIFIER_DIR="${NOTIFIER_DIR:-mx-chain-notifier-go}" + +# Commit pins +CHAIN_GO_COMMIT="757f2de643d3d69494179cd899d92b31edfbb64a" # github.com/multiversx/mx-chain-go +CHAIN_CORE_GO_COMMIT="60b4de5d3d1bb3f2a34c764f8cf353c5af8c3194" # github.com/multiversx/mx-chain-core-go + +# Endpoint check +# One or more candidate health URLs for the simulator; the first 200 wins +# (different simulator versions may expose different status routes) +VERIFY_URLS=( + "${VERIFY_URL:-http://localhost:8085/network/status/0}" + "http://localhost:8085/network/status" + "http://localhost:8085/simulator/health" + "http://localhost:8085/health" +) +VERIFY_TIMEOUT_SEC="${VERIFY_TIMEOUT_SEC:-120}" +LOG_SNIFF_INTERVAL_SEC="${LOG_SNIFF_INTERVAL_SEC:-10}" + +log() { printf "[+] %s\n" "$*" >&2; } +err() { printf "[!] %s\n" "$*" >&2; } + +need() { + command -v "$1" >/dev/null 2>&1 || { err "Missing dependency: $1"; exit 1; } +} + +need git +need go +need make +need curl +need awk + +port_open() { + local host="$1" port="$2" + (echo > /dev/tcp/$host/$port) >/dev/null 2>&1 && return 0 || return 1 +} + +start_redis_and_sentinel() { + local redis_port=6379 sentinel_port=26379 + local have_redis_server=0 + if command -v redis-server >/dev/null 2>&1; then have_redis_server=1; fi + + if port_open 127.0.0.1 "$redis_port"; then + log "Redis already running on 127.0.0.1:$redis_port" + else + if [[ "$have_redis_server" -eq 1 ]]; then + log "Starting local Redis server on port $redis_port" + nohup redis-server --port "$redis_port" > redis.out 2>&1 & + for i in {1..60}; do + if port_open 127.0.0.1 "$redis_port"; then break; fi; sleep 1; done + if ! port_open 127.0.0.1 "$redis_port"; then + err "Failed to start Redis on port $redis_port" + return 1 + fi + else + err "redis-server not found; please install Redis or start it manually on 127.0.0.1:$redis_port" + return 1 + fi + fi + + if port_open 127.0.0.1 "$sentinel_port"; then + log "Redis Sentinel already running on 127.0.0.1:$sentinel_port" + else + if [[ "$have_redis_server" -eq 1 ]]; then + log "Starting local Redis Sentinel on port $sentinel_port (master mymaster -> 127.0.0.1:$redis_port)" + local sentinel_conf + sentinel_conf=$(mktemp) + cat >"$sentinel_conf" < redis-sentinel.out 2>&1 & + for i in {1..60}; do + if port_open 127.0.0.1 "$sentinel_port"; then break; fi; sleep 1; done + if ! port_open 127.0.0.1 "$sentinel_port"; then + err "Failed to start Redis Sentinel on port $sentinel_port" + return 1 + fi + else + err "redis-server not found; cannot start Sentinel. Please start a Sentinel on 127.0.0.1:$sentinel_port with master name 'mymaster' targeting 127.0.0.1:$redis_port" + return 1 + fi + fi +} + +clone_or_update() { + local repo_url="$1" dir="$2" branch_opt="${3:-}" + if [[ -d "$dir/.git" ]]; then + log "Updating existing repo: $dir" + git -C "$dir" fetch --all --tags --prune + if [[ -n "$branch_opt" ]]; then + git -C "$dir" checkout "$branch_opt" + git -C "$dir" pull --ff-only origin "$branch_opt" || true + fi + else + log "Cloning $repo_url into $dir ${branch_opt:+(branch $branch_opt)}" + if [[ -n "$branch_opt" ]]; then + git clone --single-branch -b "$branch_opt" "$repo_url" "$dir" + else + git clone "$repo_url" "$dir" + fi + fi +} + +pin_go_deps() { + local module_dir="$1" + pushd "$module_dir" >/dev/null + log "Pinning dependencies in $(pwd)" + # Pin exact commits using go get + GOFLAGS=${GOFLAGS:-} \ + go get \ + github.com/multiversx/mx-chain-go@"$CHAIN_GO_COMMIT" \ + github.com/multiversx/mx-chain-core-go@"$CHAIN_CORE_GO_COMMIT" + + # Ensure module graph is clean + go mod tidy + popd >/dev/null +} + +build_chainsimulator() { + local module_dir="$1" + pushd "$module_dir" >/dev/null + log "Building chainsimulator binary" + go build -v ./cmd/chainsimulator + popd >/dev/null +} + +dummy_run_generate_configs() { + local module_dir="$1" + local cmd_dir="$module_dir/cmd/chainsimulator" + pushd "$cmd_dir" >/dev/null + log "Dummy run to fetch/generate configs (fetch-configs-and-close)" + # Build binary here so relative ./config paths resolve correctly + go build -v . + ./chainsimulator --fetch-configs-and-close + popd >/dev/null +} + +patch_external_toml() { + local module_dir="$1" + local toml_path="$module_dir/cmd/chainsimulator/config/node/config/external.toml" + if [[ ! -f "$toml_path" ]]; then + err "Config file not found: $toml_path" + exit 1 + fi + log "Patching HostDriversConfig in $toml_path (Enabled=true, MarshallerType=\"gogo protobuf\")" + local tmp + tmp="$(mktemp)" + awk ' + BEGIN { inside=0 } + /^\[\[HostDriversConfig\]\]/ { inside=1; print; next } + /^\[/ { if (inside) inside=0 } + { + if (inside && $0 ~ /^[[:space:]]*Enabled[[:space:]]*=/) { $0=" Enabled = true" } + if (inside && $0 ~ /^[[:space:]]*MarshallerType[[:space:]]*=/) { $0=" MarshallerType = \"gogo protobuf\"" } + print + } + ' "$toml_path" > "$tmp" && mv "$tmp" "$toml_path" +} + +enable_ws_connector() { + local notifier_dir="$1" + local toml_path="$notifier_dir/cmd/notifier/config/config.toml" + if [[ ! -f "$toml_path" ]]; then + err "Notifier config not found: $toml_path" + exit 1 + fi + log "Enabling WebSocketConnector in $toml_path" + local tmp + tmp="$(mktemp)" + awk ' + BEGIN { inside=0 } + /^\[WebSocketConnector\]/ { inside=1; print; next } + /^\[/ { if (inside) inside=0 } + { + if (inside && $0 ~ /^[[:space:]]*Enabled[[:space:]]*=/) { $0="Enabled = true" } + print + } + ' "$toml_path" > "$tmp" && mv "$tmp" "$toml_path" +} + +# Force Redis and Sentinel hosts to IPv4 loopback to avoid ::1 resolution issues +patch_notifier_redis_hosts() { + local notifier_dir="$1" + local toml_path="$notifier_dir/cmd/notifier/config/config.toml" + if [[ ! -f "$toml_path" ]]; then + err "Notifier config not found: $toml_path" + exit 1 + fi + log "Patching notifier Redis hosts in $toml_path (localhost/::1 -> 127.0.0.1; sentinel name -> mymaster)" + # Replace common host patterns to 127.0.0.1 and ensure sentinel/master names are set to mymaster + sed -i.bak \ + -e 's/localhost/127.0.0.1/g' \ + -e 's/\[::1\]/127.0.0.1/g' \ + -e 's/sentinelName\s*=\s*"[^"]*"/sentinelName = "mymaster"/g' \ + -e 's/masterName\s*=\s*"[^"]*"/masterName = "mymaster"/g' \ + "$toml_path" || true +} + +# Optionally patch the WebSocketConnector path/host/port if provided via env +patch_notifier_ws_endpoint() { + local notifier_dir="$1" + local toml_path="$notifier_dir/cmd/notifier/config/config.toml" + local ws_path="${NOTIFIER_WS_PATH:-}" + local ws_host="${NOTIFIER_WS_HOST:-127.0.0.1}" + local ws_port="${NOTIFIER_WS_PORT:-22111}" + if [[ ! -f "$toml_path" ]]; then + err "Notifier config not found: $toml_path" + exit 1 + fi + if [[ -n "$ws_path" ]]; then + log "Patching WebSocketConnector path to '${ws_path}', host to '${ws_host}', port to '${ws_port}' in $toml_path" + sed -i.bak \ + -e "s#^\([[:space:]]*Path[[:space:]]*=\).*#\1 \"${ws_path}\"#" \ + -e "s#^\([[:space:]]*Url[[:space:]]*=\).*#\1 \"${ws_host}:${ws_port}\"#" \ + "$toml_path" || true + fi +} + +start_notifier() { + local notifier_dir="$1" + pushd "$notifier_dir" >/dev/null + printf "[+] %s\n" "Starting notifier via 'make run' in background" >&2 + # Run in background, redirect logs + nohup make run > notifier.out 2>&1 & + local pid=$! + popd >/dev/null + echo "$pid" +} + +start_chainsimulator() { + local module_dir="$1" + local cmd_dir="$module_dir/cmd/chainsimulator" + pushd "$cmd_dir" >/dev/null + printf "[+] %s\n" "Starting chainsimulator in background" >&2 + # Build if missing + if [[ ! -x ./chainsimulator ]]; then + go build -v . + fi + nohup ./chainsimulator > chainsimulator.out 2>&1 & + local pid=$! + popd >/dev/null + echo "$pid" +} + +wait_for_http_200_any() { + local -n urls_ref=$1 + local timeout_sec="$2" + local chain_log="$3" notifier_log="$4" + local start_ts now code last_log_print=0 tmp body_preview url + start_ts=$(date +%s) + log "Waiting for simulator to be ready on any of: ${urls_ref[*]} (timeout ${timeout_sec}s)" + while true; do + for url in "${urls_ref[@]}"; do + tmp=$(mktemp) + code=$(curl -s -o "$tmp" -w "%{http_code}" "$url" || true) + if [[ "$code" == "200" ]]; then + log "Received HTTP 200 from $url" + body_preview=$(head -c 2000 "$tmp" | tr -d '\r') + printf "[+] %s\n%s\n" "Status body preview:" "$body_preview" >&2 + rm -f "$tmp" + return 0 + fi + rm -f "$tmp" + done + + now=$(date +%s) + if (( now - last_log_print >= LOG_SNIFF_INTERVAL_SEC )); then + last_log_print=$now + printf "[!] %s\n" "Simulator not ready yet (no 200 from any URL)" >&2 + if [[ -f "$chain_log" ]]; then + printf "[!] %s\n" "Tail of chainsimulator logs:" >&2 + tail -n 60 "$chain_log" >&2 || true + fi + if [[ -f "$notifier_log" ]]; then + printf "[!] %s\n" "Tail of notifier logs:" >&2 + tail -n 40 "$notifier_log" >&2 || true + fi + fi + + if (( now - start_ts > timeout_sec )); then + err "Timeout waiting for HTTP 200 from any simulator status URL" + return 1 + fi + sleep 2 + done +} + +main() { + # 1) Clone simulator + clone_or_update "$SIM_REPO_URL" "$SIM_DIR" + + # 2) Pin deps to requested commits + pin_go_deps "$SIM_DIR" + + # 3) Build chainsimulator + build_chainsimulator "$SIM_DIR" + + # 4) Dummy run to ensure configs are materialized on disk + dummy_run_generate_configs "$SIM_DIR" + + # 5) Patch external.toml HostDriversConfig + patch_external_toml "$SIM_DIR" + + # 6) Clone notifier at branch + clone_or_update "$NOTIFIER_REPO_URL" "$NOTIFIER_DIR" "$NOTIFIER_BRANCH" + + # 7) Enable WebSocketConnector in notifier config + enable_ws_connector "$NOTIFIER_DIR" + patch_notifier_redis_hosts "$NOTIFIER_DIR" + patch_notifier_ws_endpoint "$NOTIFIER_DIR" + + # 8) Ensure Redis + Sentinel are running locally for notifier + start_redis_and_sentinel || { + err "Redis/Sentinel setup failed; notifier may not start correctly" + } + + # 9) Start notifier first + notifier_pid=$(start_notifier "$NOTIFIER_DIR") + log "Notifier PID: $notifier_pid" + + # 9.1) Wait for Notifier WS port to be open before starting simulator + local notifier_host="127.0.0.1" notifier_port=22111 + log "Waiting for Notifier WS ${notifier_host}:${notifier_port} to accept connections..." + for i in {1..60}; do + if port_open "$notifier_host" "$notifier_port"; then + log "Notifier WS is up" + break + fi + sleep 1 + done + if ! port_open "$notifier_host" "$notifier_port"; then + err "Notifier WS did not open on ${notifier_host}:${notifier_port} in time" + fi + + # 10) Start chain simulator next + chainsim_pid=$(start_chainsimulator "$SIM_DIR") + log "ChainSimulator PID: $chainsim_pid" + + # 11) Verify HTTP 200 after both are up + local chain_log="$SIM_DIR/cmd/chainsimulator/chainsimulator.out" + local notifier_log="$NOTIFIER_DIR/notifier.out" + if ! wait_for_http_200_any VERIFY_URLS "$VERIFY_TIMEOUT_SEC" "$chain_log" "$notifier_log"; then + err "Verification failed. See $NOTIFIER_DIR/notifier.out for logs." + exit 1 + fi + + log "All done. Notifier (PID $notifier_pid) and ChainSimulator (PID $chainsim_pid) are running." + log "Notifier logs: $NOTIFIER_DIR/notifier.out" + log "ChainSimulator logs: $SIM_DIR/cmd/chainsimulator/chainsimulator.out" +} + +main "$@" diff --git a/src/test/scripts/stop-custom-cs.sh b/src/test/scripts/stop-custom-cs.sh new file mode 100644 index 000000000..00a1651b2 --- /dev/null +++ b/src/test/scripts/stop-custom-cs.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Best-effort stop of locally started custom ChainSimulator and Notifier + +log() { printf "[stop-custom-cs] %s\n" "$*"; } + +# Attempt to kill chainsimulator started via setup-simulator-and-notifier.sh +if pgrep -f "/cmd/chainsimulator/chainsimulator" >/dev/null 2>&1; then + log "Stopping chainsimulator..." + pkill -f "/cmd/chainsimulator/chainsimulator" || true + sleep 1 +fi + +# Attempt to stop notifier started via `make run` (binary name typically 'notifier') +if pgrep -f "mx-chain-notifier-go" >/dev/null 2>&1; then + log "Stopping notifier (by repo path)..." + pkill -f "mx-chain-notifier-go" || true + sleep 1 +elif pgrep -f "/notifier" >/dev/null 2>&1; then + log "Stopping notifier (by binary)..." + pkill -f "/notifier" || true + sleep 1 +fi + +log "Done" +