Merged iq-eth-gateway into this repo. One codebase, two chains, selected at
boot by IQ_CHAIN (solana default | evm). Implements PR #10's seam.
src/chain/*.ts(Solana) →src/chain/solana/.iq-eth-gateway/src/chain/*→src/chain/evm/(npm@iqlabs-official/ethereum-sdk@0.2.2, not afile:link).- New
src/chain/types.ts—ChainReaderinterface (the shared intersection only). - New
src/chain/index.tsselector — picks shared names from the active adapter, re-exports both adapters' chain-specific names (no collisions) so both route sets type-check;server.tsmounts only the active set. - EVM routes →
src/routes/evm/; EVM OpenAPI →src/openapi.evm.ts; EVM catalog ingest (txHash row shape) →src/cache/catalog-ingest.evm.ts. src/utils.tsgainedisTxHash/isEvmAddressalongsideisValidPublicKey.src/cache/{disk,store}.tsCacheType union extended with"ens"(superset).
Both adapter modules must be import-safe so loading the inactive one (the barrel
imports both to build the selector) can't crash the active chain. The EVM reader
used to throw at module top-level if IQETH_NETWORK was invalid. That throw
(plus iqlabs.setNetwork + provider construction) moved into initEvm(), called
from initChain() only when IQ_CHAIN=evm. NETWORK/NETWORK_CONFIG fall back
to a default at import; strict validation happens in initEvm(). Solana side-
effects are harmless (no env throw, no network call), so its init is a no-op.
PR #10's premise — "only src/chain differs" — is partially wrong in the code:
routes diverge by id format (base58 vs 0x), row field names (__txSignature
vs __txHash), validation, and Solana-only site/SNS hosting. Forcing one route
file per endpoint would mean rewriting working, live code. So the merge keeps the
Solana route set byte-identical (zero regression on gateway.iqlabs.dev) and
adds the EVM set as a parallel, conditionally-mounted directory.
bun build --target bunclean (973 modules, both chain stacks).- Solana boot: cluster validated,
/snsmounted,/ens404. - EVM boot: chainId validated,
/ensmounted,/sns404; 24/24 endpoint sweep → 200. bun test: 45/45 (39 Solana + 6 EVM).
readMultipleRows used heliusBatchGetTransactions (single HTTP call) then passed raw JSON
to reader.readUserInventoryCodeInFromTx(). The SDK expects proper web3.js objects with
message.getAccountKeys() — raw JSON doesn't have class methods. Every batch decode threw
message.getAccountKeys is not a function, caught by Promise.allSettled, silently returning
0 rows. Production worked only because disk cache was populated via the fallback path
(readSingleRow which uses Connection.getTransaction() and returns proper objects).
Fix: Added decodeRawTxRow() — decodes instructions directly from raw JSON via
BorshInstructionCoder. Handles inline data posts (on_chain_path empty). Falls back to
readSingleRow for session/linked-list posts that need the full SDK read flow.
Added if (rows.length > 0) before setDiskCache("rows", ...) in the rows endpoint.
Previously, a failed decode (0 rows) would cache an empty response to disk permanently.
- Removed dead
activeRpcvariable (written, never read) - Removed unused
VersionedTransactionResponseandreaderimports - Removed
parseTransactionToRow(replaced bydecodeRawTxRow) - Inlined
optsinfetchRecentSignatures - Removed slop comments
db_code_in(Zo's new write flow) works fine with SDK 0.1.14 — the IDL has it,CODE_IN_INSTRUCTION_NAMESincludes it,BorshInstructionCoderdecodes named fields correctly.- gTFA not used in rows endpoint — per-sig caching (24h, immutable on-chain data) is more efficient than re-downloading all full txs every time.
- Helius batch still used for fetching raw txs (1 HTTP vs N), just decoded differently now.
The gateway is a plain container; the deploy target is the operator's choice. The only build gotcha worth recording:
# push a classic Docker manifest v2 image (some runtimes reject OCI-only).
# build-and-push.sh pins the buildx flags for this:
# --provenance=false --sbom=false --output=...,oci-mediatypes=false
./scripts/build-and-push.sh v16 0.2.2 latestMount /app/cache (CACHE_DIR) on a persistent volume so it survives image
swaps — see "Redeploy preserves cache" below.
Added GET /cache/info + GET /cache/snapshot for peer bootstrap of cold gateways. Read-only — preserves the gateway's "no writes over HTTP" property. Operators warm a cold instance with scripts/bootstrap-cache-from-peer.sh.
tar.gz of CACHE_DIR with a VACUUM-INTO consistent cache.db. Excludes WAL/SHM journal files (recipient sqlite would reject those from a different write epoch).
const db = new Database(liveDb, { readonly: true });
db.run(`VACUUM INTO '${stageDb}'`); // bun:sqlite can't bind path as a parameterFalls back to cp of the live db if VACUUM fails.
The gateway treats CACHE_DIR (/app/cache) as durable: a redeploy that only swaps the image should keep the same volume, so the cache survives. Whatever orchestrator runs the container, mount cache on storage whose lifecycle is independent of the container (retain-on-delete) so an accidental container/pod removal doesn't wipe the data.
GET /cache/snapshot now streams tar -czf - . directly to the response (no buffer-to-file step). Avoids Cloudflare's 100s edge timeout on big caches and keeps memory pressure low.
getDiskCache falls back to a canonical pathFor(type, key) reconstruction when the stored path doesn't resolve — peer-bootstrapped caches (where the writer's CACHE_DIR may differ from ours) still serve hits without manual fixup.
scripts/bootstrap-cache-from-peer.sh <peer> [cache-dir] streams a peer's snapshot into a cache directory. Stopping/starting the gateway around it (and any volume wiring) is left to the operator's platform — the script stays deployment-agnostic.
scripts/build-and-push.sh <tag>... pins the buildx flags so we never accidentally push an OCI-only image again (Akash's runtime requires the classic Docker manifest v2 format).