Migrate a Squid to Portal
Walks the migration of an existing Squid SDK indexer onto the Portal data source. EVM and Solana have different package sets; the migration shape (data source + types + field selection) is parallel. Upstream doc (unified, both chains): https://docs.sqd.dev/en/sdk/migration/gateway-to-portal.
The v2 gateways are actively being sunset. On 2026-08-05, 18 datasets (including
astar-mainnet,berachain-mainnet,celo-mainnet,linea-mainnet,scroll-mainnet,taiko-mainnet,unichain-mainnet,zksync-mainnet,zora-mainnet) became Portal-only. A v2 squid on those chains is already broken and must migrate. On 2026-08-20, 67 low-usage datasets retired from both v2 and Portal (announcement); there is no Portal migration target for them, so move to RPC or a private Portal instead. The July 30, 2026 Squid SDK release also introduced the@subsquid/squid-sdkumbrella package with an automatic Portal/RPC failover data source. Consider it for new work, though this guide's@subsquid/evm-streamtarget remains current.
When to use this skill
Activate when the user says any of:
- "migrate my squid to Portal" (EVM or Solana)
- "move off v2 archive /
setGateway/setDataSource/lookupArchive" - "upgrade
@subsquid/evm-processor" or "@subsquid/solana-streamto1.x" - references
https://v2.archive.subsquid.io/... - mentions removing
SolanaRpcClient - asks about Solana block-height-to-slot conversion
- hits compile errors on
EvmBatchProcessor,evmLog,block.height, orblock.header.slotafter a bump - needs to migrate
processor.setPrometheusPort()/processor.setPrometheusServer()(EVM only — removed withEvmBatchProcessor)
Pre-flight
Identify the chain. Grep:
grep -RE "@subsquid/(evm-processor|solana-stream|archive-registry)" --include="*.ts" --include="*.json" .@subsquid/evm-processor→ EVM section below@subsquid/solana-stream@^0.x→ Solana section below
Verify that the dataset is active, then verify its Portal slug. First check the August 2026 retirement list. Any listed dataset stopped ingesting on August 20 and has no Portal migration target. Then map the old archive URL
https://v2.archive.subsquid.io/network/<slug>to the Portal URL:curl -sI https://portal.sqd.dev/datasets/<slug>/metadata200 OK= catalog entry exists.404= wrong or removed slug; search at https://portal.sqd.dev/datasets. A200does not override an announced retirement; always perform the retirement check first.Inventory direct RPC calls in the batch handler. If the handler uses
new abi.Contract(ctx, header, address)orMulticall(...)for contract reads, the migration needs the optional RPC-client step.Determine whether the source squid uses real-time data. Check for
.setRpcEndpoint()(EVM) or.setRpc()(Solana) alongside.setGateway(). A gateway-only squid (no RPC) was processing finalized data only — preserve that mode after the migration by settingsupportHotBlocks: falseon the database (see the EVM Step 7 / Solana Step 4 notes). A squid with both was real-time; defaultsupportHotBlocks: true.Check for prior Portal-beta usage. If the squid already calls
.setPortal(...)(from the Portal beta) alongside.setRpcEndpoint(), treat the existing.setPortalcall the same way as a.setGateway— remove it and the surrounding processor initialization, then set up the new Portal data source as below.Commit / branch first. The migration touches imports, types, and the processor entrypoint.
Node.js 22+ is required.
Add the v2 gateway API key — EVM only (alternative to migrating)
Only use the v2-auth path when the user explicitly wants to remain on an EVM v2 gateway that still exists. For versions, environment setup, and the setGateway({ url, apiKey }) form, read references/v2-auth.md. On Solana, follow the Portal migration below instead.
EVM migration
Step 1 — Swap packages
npm uninstall @subsquid/evm-processor @subsquid/archive-registry
npm i @subsquid/evm-stream @subsquid/evm-objects @subsquid/batch-processor @subsquid/logger
If the handler makes direct RPC calls (contract state, Multicall):
npm i @subsquid/rpc-client
@subsquid/util-internal and @subsquid/util-internal-hex get pulled in transitively — no explicit install needed; retarget imports per Step 2.
Step 2 — Imports
// before
import {
BlockHeader,
DataHandlerContext,
EvmBatchProcessor,
EvmBatchProcessorFields,
Log as _Log,
Transaction as _Transaction,
BlockData as _BlockData,
FieldSelection,
decodeHex,
assertNotNull,
} from '@subsquid/evm-processor'
import {lookupArchive} from '@subsquid/archive-registry' // older squids
// after
import * as evmObjects from '@subsquid/evm-objects'
import {DataSourceBuilder, FieldSelection} from '@subsquid/evm-stream'
import type {DataHandlerContext as BaseDataHandlerContext} from '@subsquid/batch-processor'
import type {Logger} from '@subsquid/logger'
import {decodeHex} from '@subsquid/util-internal-hex' // if used
import {assertNotNull} from '@subsquid/util-internal' // if used
// `lookupArchive` is gone — use the Portal URL directly.
Mapping:
| Old import | New location |
|---|---|
EvmBatchProcessor |
gone — replaced by DataSourceBuilder from @subsquid/evm-stream |
EvmBatchProcessorFields<typeof processor> |
gone — derive Fields from a literal fields object via typeof fields |
FieldSelection |
@subsquid/evm-stream |
BlockHeader, Log, Transaction, Trace, StateDiff |
@subsquid/evm-objects |
BlockData |
@subsquid/evm-objects — renamed to Block (see Step 3) |
DataHandlerContext |
@subsquid/batch-processor (base; augment manually) |
decodeHex |
@subsquid/util-internal-hex |
assertNotNull |
@subsquid/util-internal |
lookupArchive |
gone — use Portal URL directly |
Step 3 — Type aliases
a) Hoist setFields(...) into a literal — EvmBatchProcessorFields<typeof processor> is gone.
export const fields = {
block: { timestamp: true },
log: {
address: true,
topics: true,
data: true,
transactionHash: true,
},
} satisfies FieldSelection
export const dataSource = new DataSourceBuilder()
// ...
.setFields(fields)
// ...
.build()
export type Fields = typeof fields
The satisfies FieldSelection keeps the precise shape of fields instead of widening it to FieldSelection. Without it, evmObjects.Log<Fields> (Step 3c) degrades to "all fields".
b) Block ↔ BlockData swap.
- Old:
Block = BlockHeader<F>(header only);BlockData = BlockData<F>(full payload). - New:
BlockHeader<F>= header only;Block<F>= full payload. The "Data" suffix is gone.
// before
export type Block = BlockHeader<Fields>
export type BlockData = _BlockData<Fields>
// after
export type Block = evmObjects.BlockHeader<Fields> // header only
export type BlockData = evmObjects.Block<Fields> // full payload
Code that expected header-only via Block will now type-check against the full payload at compile time and behave differently at runtime. Rename callsites accordingly.
c) DataHandlerContext generic argument order is flipped, and the base no longer carries log or _chain.
- Old:
DataHandlerContext<Store, Fields>. Exposeslogand_chainby default. - New:
DataHandlerContext<BlockData, Store>(block payload first, store second). Bare{store, blocks, isHead}; you re-attachlog(Step 5) and_chain(Step 6) manually.
// after, no RPC
export type Context<Store> = BaseDataHandlerContext<BlockData, Store> & {
log: Logger
}
// after, with RPC
import type {RpcClient} from '@subsquid/rpc-client'
export type Context<Store> = BaseDataHandlerContext<BlockData, Store> & {
log: Logger
_chain: { client: RpcClient }
}
Store is the user's store generic — typically Store re-exported from @subsquid/typeorm-store (import {Store, TypeormDatabase} from '@subsquid/typeorm-store'). At the call site you'd write Context<Store>.
Step 4 — Rewrite the data source
// after
const dataSource = new DataSourceBuilder()
.setPortal({
url: 'https://portal.sqd.dev/datasets/ethereum-mainnet',
http: { retryAttempts: Infinity },
})
.setBlockRange({ from: 6_082_465 })
.setFields(fields)
.addLog({
where: { address: [CONTRACT], topic0: [TOPIC] },
include: { transaction: true },
range: { from: 6_082_465 },
})
.build()
Full before/after for both starting shapes (recent setGateway/setRpcEndpoint/setFinalityConfirmation and the older lookupArchive + setDataSource({archive, chain})) is in references/evm-example-diff.md.
Structural changes:
setRpcEndpoint/setFinalityConfirmation/setDataSourceare gone. Portal handles real-time delivery and finality. All three v2 shapes (setGateway/setDataSource) collapse to a single.setPortal(...)call. Both forms are valid: bare URL (.setPortal('https://portal.sqd.dev/datasets/<slug>'), matching the upstream EVM doc) or object form (.setPortal({ url, http: { retryAttempts: Infinity } })). The object form is recommended for production because it lets you raisehttp.retryAttemptsso the indexer doesn't exit on transient non-2xx Portal responses..addLog()/.addTransaction()/.addTrace()/.addStateDiff()arguments are split into three keys. Filters underwhere, related items underinclude, block range underrange. The flat shape ({ address, topic0, transaction: true, range }) is rejected by the new type..setFields({ evmLog: ... })→.setFields({ log: ... }). The selector key for log fields was renamed..build()must be called at the end of the chain. Without it, calls likegetBlockStreamare not on the builder type.
Block-selector restriction: .setFields({ block: { ... } }) only accepts the mutable header fields (timestamp, nonce, miner, extraData, etc.). number, hash, and parentHash are always present on the block header — requesting them in the selector is a TS error.
Step 5 — Wire up run() and augmentBlock
import {run} from '@subsquid/batch-processor'
import {augmentBlock} from '@subsquid/evm-objects'
import {createLogger} from '@subsquid/logger'
const logger = createLogger('sqd:processor:mapping')
run(dataSource, db, async (simpleCtx) => {
const ctx: Context<Store> = {
...simpleCtx,
blocks: simpleCtx.blocks.map(augmentBlock),
log: logger,
// _chain: { client: rpcClient } // see Step 6
}
// rest of handler unchanged
})
The old processor.run(db, handler) becomes a free run(dataSource, db, handler). Two things now happen inside the handler that used to be automatic:
augmentBlock()each block. Raw blocks from the stream are flat objects;augmentBlockadds the convenience back-references (log.transaction,log.block,block.logs[*].id, etc.).- Attach the logger manually. The new base context doesn't carry
log.
Block-shortcut renames (the old top-level block.X shortcuts are gone; the values now live under block.header):
block.height→block.header.number(.heightis also present on the newBlockHeaderbut marked@deprecated; prefer.number)block.timestamp→block.header.timestamplog.block.height→log.block.number(afteraugmentBlock)
processor.setPrometheusPort() / processor.setPrometheusServer() are gone with EvmBatchProcessor. Move them onto a PrometheusServer instance and hand it to run() via the prometheus option (the runner still attaches the built-in sqd_processor_* metrics and calls .serve() itself):
-processor.setPrometheusPort(3000)
-processor.setPrometheusServer(myServer)
-processor.run(db, async (ctx) => { /* ... */ })
+import {run, PrometheusServer} from '@subsquid/batch-processor'
+
+const prometheus = new PrometheusServer()
+prometheus.setPort(3000)
+// prometheus.addMetricsSink({ register(registry) { /* custom prom-client metrics */ } })
+
+run(dataSource, db, async (simpleCtx) => { /* ... */ }, {prometheus})
prometheus.setPort() takes precedence over PROCESSOR_PROMETHEUS_PORT / PROMETHEUS_PORT (which still work as the default if setPort is skipped). For custom metrics, declare them with prom-client and register on the server's private registry via addMetricsSink() so they share the /metrics endpoint with sqd_processor_* — see squid-evm-rt-template for a worked example.
Step 6 — (Optional) RPC client for direct chain reads
Skip if the handler doesn't make direct RPC calls (no new abi.Contract(ctx, header, address), no ctx._chain.client.call(...)).
import {RpcClient} from '@subsquid/rpc-client'
const rpcClient = new RpcClient({
url: process.env.RPC_URL!,
rateLimit: 100,
})
run(dataSource, db, async (simpleCtx) => {
const ctx: Context<Store> = {
...simpleCtx,
blocks: simpleCtx.blocks.map(augmentBlock),
log: logger,
_chain: { client: rpcClient },
}
// Contract reads via new abi.Contract(ctx, header, address) work unchanged.
})
Step 7 — Hot blocks on the store
For a real-time squid (the v2 source had both .setGateway() and .setRpcEndpoint()):
const db = new TypeormDatabase({ supportHotBlocks: true })
{supportHotBlocks: true} here is just to keep things explicit: it is the default. This regime is required for the store to apply forked-block rollbacks once the indexer reaches the chain head. Other store backends accept the same option.
For a finalized-only squid (the v2 source had .setGateway() only, no .setRpcEndpoint() — the regime where the squid processes only finalized data, suitable for forwarding into append-only destinations), preserve that behavior by explicitly setting:
const db = new TypeormDatabase({ supportHotBlocks: false })
A Portal data source streaming into a target with supportHotBlocks: false automatically ingests from /finalized-stream instead of /stream.
Step 8 — Field selection
The new stream fetches only the fields listed in .setFields(). The v2 processor merged a default set (log.address, log.topics, log.data, block.timestamp, transaction.from/to/hash) into the user's selection — the new stream does not, and TypeScript enforces it.
Typical minimum for a log-indexing squid:
const fields = {
block: { timestamp: true },
log: {
address: true,
topics: true,
data: true,
transactionHash: true,
},
transaction: { // only if include: { transaction: true }
from: true,
to: true,
hash: true,
value: true,
},
} satisfies FieldSelection
If the handler reaches into a field not listed, TS rejects the access.
Step 9 — Re-sync the squid
After the code compiles and a local run succeeds, re-sync from genesis so the new data path is exercised across the full history (catches bugs early).
If deployed to SQD Cloud, use the zero-downtime procedure: deploy into a new slot, wait for it to sync, then move the production tag to the new deployment (see slots and tags). If you can't afford a re-sync, re-deploy the squid without resetting its database (Cloud) or just restart it with its code updated (self-hosted).
Solana migration
Step 1 — Code cleanup, then upgrade packages
Before bumping, remove SolanaRpcClient references from the source — they will not compile after the bump:
-import {DataSourceBuilder, SolanaRpcClient} from '@subsquid/solana-stream'
+import {DataSourceBuilder} from '@subsquid/solana-stream'
const dataSource = new DataSourceBuilder()
.setGateway('https://v2.archive.subsquid.io/network/solana-mainnet')
- .setRpc(process.env.SOLANA_NODE == null ? undefined : {
- client: new SolanaRpcClient({ url: process.env.SOLANA_NODE }),
- strideConcurrency: 10,
- })
// ...
Then upgrade:
npx --yes npm-check-updates --filter "@subsquid/*" --target "@latest" --upgrade
npm install # or pnpm install / yarn install
This bumps @subsquid/solana-stream to ^1.x.x, @subsquid/solana-objects to ^1.x.x, @subsquid/batch-processor to ^1.x.x, and @subsquid/typeorm-store to ^1.x.x.
Step 2 — Rewrite the data source
// before
const dataSource = new DataSourceBuilder()
.setGateway('https://v2.archive.subsquid.io/network/solana-mainnet')
.setBlockRange({ from: 289_819_150 }) // BLOCK HEIGHT
// ... .setFields(...), .addInstruction(...)
.build()
// after
const dataSource = new DataSourceBuilder()
.setPortal({
url: 'https://portal.sqd.dev/datasets/solana-mainnet',
http: { retryAttempts: Infinity },
})
.setBlockRange({ from: 317_617_480 }) // SLOT NUMBER
// ... .setFields(...), .addInstruction(...)
.build()
Structural changes:
- Replace
.setGateway(...)(and.setRpc({...})if present) with.setPortal({ url, http }).http: { retryAttempts: Infinity }is recommended for production — without it the indexer exits on transient non-2xx Portal responses. - Convert the block-range
fromfrom a block height to a slot number. Solana exposes both; the v2 archive used heights, Portal uses slots. Use the interactive bisection converter embedded at https://docs.sqd.dev/en/sdk/migration/height-to-slot (binary-searches the public Portal).
Selectors inside .addInstruction({ where, include }) and field selection in .setFields({...}) keep the same shape.
Step 3 — Block-header rename in the handler
block.header.slot → block.header.number. The Portal BlockHeader only exposes .number, which on Solana represents the slot.
let exchange = new Exchange({
id: ins.id,
- slot: block.header.slot,
+ slot: block.header.number,
// ...
})
Same for transaction.block.slot / instruction.block.slot if those were traversed.
Step 4 — Hot blocks on the store
For a real-time squid (the v2 source had both .setGateway() and .setRpc()) use
const database = new TypeormDatabase()
or
const database = new TypeormDatabase({ supportHotBlocks: true })
- these are equivalent. Hot blocks support is required to apply Portal's hot-block updates once the indexer reaches the chain head.
For a finalized-only squid (the v2 source had .setGateway() only, no .setRpc()), explicitly set:
const database = new TypeormDatabase({ supportHotBlocks: false })
A Portal data source streaming into such a target automatically ingests from /finalized-stream instead of /stream, preserving the finalized-only regime.
Step 5 — Field selection
The Portal solana-stream (^1.x.x) fetches only the fields listed in .setFields(). There is no default set — beyond a small set of always-required identity/index fields (block.number/.hash/.parentHash, transaction.transactionIndex, instruction.transactionIndex/.instructionAddress, etc.), every field must be requested explicitly. TypeScript enforces this: accessing a field not in your selection is a compile error.
v2 was different — it merged a default set on top of your selection, so a partial .setFields() still worked at runtime. The full v2 default table is in references/common-errors.md ("Property 'signatures' does not exist..."). The most-missed cases on a real squid: tokenBalance.preMint / postMint (commonly used to recover which token is involved in a swap), transaction.signatures, block.header.timestamp.
Typical minimum for a swap-style instruction handler:
.setFields({
block: { timestamp: true },
transaction: {
signatures: true,
accountKeys: true,
},
instruction: {
programId: true,
accounts: true,
data: true,
},
tokenBalance: {
preAmount: true,
postAmount: true,
preOwner: true,
postOwner: true,
preMint: true,
postMint: true,
},
})
block.header.number (the slot on Solana) is always available without being requested. Block height is not — request via block: { height: true } if your handler reads it.
Step 6 — Re-sync the squid
After the code compiles and a local run succeeds, re-sync from genesis so the new data path is exercised across the full history (catches bugs early).
If deployed to SQD Cloud, use the zero-downtime procedure: deploy into a new slot, wait for it to sync, then move the production tag to the new deployment (see slots and tags).
If you can't afford a re-sync, re-deploy the squid without resetting its database (Cloud) or just restart it (self-hosted). On Solana, if the existing DB stores block heights and the new code expects slots in the status row, see https://docs.sqd.dev/en/sdk/migration/solana-resync-workaround.
References
references/evm-example-diff.md— canonical USDC-transfers diffreferences/solana-example-diff.md— canonical Whirlpool-swap diffreferences/common-errors.md— full table of TS / install / runtime errors with one-line fixes, grouped by chain (and "both chains" for v2-with-apiKey)
Related skills
- portal — query the same Portal datasets directly once migrated.
- squid-perf — compare sync time between v2 and Portal deployments.