diff --git a/sdk/packages/indexer/scripts/generate-chain-yamls.ts b/sdk/packages/indexer/scripts/generate-chain-yamls.ts index ab0506665..4cc8489a4 100755 --- a/sdk/packages/indexer/scripts/generate-chain-yamls.ts +++ b/sdk/packages/indexer/scripts/generate-chain-yamls.ts @@ -33,6 +33,9 @@ const substrateTemplate = Handlebars.compile( fs.readFileSync(path.join(templatesDir, "substrate-chain.yaml.hbs"), "utf8"), ) const evmTemplate = Handlebars.compile(fs.readFileSync(path.join(templatesDir, "evm-chain.yaml.hbs"), "utf8")) +const solanaTemplate = Handlebars.compile( + fs.readFileSync(path.join(templatesDir, "solana-chain.yaml.hbs"), "utf8"), +) const multichainTemplate = Handlebars.compile(fs.readFileSync(path.join(templatesDir, "multichain.yaml.hbs"), "utf8")) const EVM_TRACKED = [ @@ -196,12 +199,66 @@ const generateEvmYaml = async (chain: string, config: Configuration) => { return evmTemplate(templateData) } +const generateSolanaYaml = async (chain: string, config: Configuration) => { + const endpoints = generateEndpoints(chain) + + let blockNumber: number + // Only connect to RPC when we actually need the live head (local env). + // For other environments we use the static startBlock from config. + if (skipRpc || currentEnv !== "local") { + blockNumber = config.startBlock + } else { + const rpcUrl = endpoints[0] + const response = await fetch(rpcUrl as string, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "getSlot", + // finalized commitment is the only safe head for state commitments; + // see solana-indexer-plan.md sections 2.2 and 6. + params: [{ commitment: "finalized" }], + }), + }) + const data = await response.json() + blockNumber = Number(data.result) + } + + const templateData = { + name: chain, + description: `${chain.charAt(0).toUpperCase() + chain.slice(1)} Indexer`, + runner: { + node: { + name: "@subql/node-solana", + version: ">=1.0.0", + }, + }, + config, + endpoints, + blockNumber, + } + + return solanaTemplate(templateData) +} + async function generateAllChainYamls() { for (const [chain, config] of validChains) { - const yaml = - config.type === "substrate" - ? await generateSubstrateYaml(chain, config) - : await generateEvmYaml(chain, config) + let yaml: string + switch (config.type) { + case "substrate": + yaml = await generateSubstrateYaml(chain, config) + break + case "evm": + yaml = await generateEvmYaml(chain, config) + break + case "solana": + yaml = await generateSolanaYaml(chain, config) + break + } fs.writeFileSync(root + `/src/configs/${chain}.yaml`, yaml) console.log(`Generated ${root}/src/configs/${chain}.yaml`) @@ -239,9 +296,10 @@ const generateChainsByIsmpHost = () => { const chainsByIsmpHost = {} validChains.forEach((config) => { - // Only include EVM chains with ethereumHost contract if (config.type === "evm" && config.contracts?.ethereumHost) { chainsByIsmpHost[config.stateMachineId] = config.contracts.ethereumHost + } else if (config.type === "solana" && config.contracts?.ismpHost) { + chainsByIsmpHost[config.stateMachineId] = config.contracts.ismpHost } }) @@ -255,9 +313,10 @@ const generateChainsTokenGatewayAddresses = () => { const tokenGateway = {} validChains.forEach((config) => { - // Only include EVM chains with ethereumHost contract if (config.type === "evm" && config.contracts?.tokenGateway) { tokenGateway[config.stateMachineId] = config.contracts.tokenGateway + } else if (config.type === "solana" && config.contracts?.tokenGateway) { + tokenGateway[config.stateMachineId] = config.contracts.tokenGateway } }) @@ -271,9 +330,10 @@ const generateChainsIntentGatewayAddresses = () => { const intentGateway = {} validChains.forEach((config) => { - // Only include EVM chains with ethereumHost contract if (config.type === "evm" && config.contracts?.intentGateway) { intentGateway[config.stateMachineId] = config.contracts.intentGateway + } else if (config.type === "solana" && config.contracts?.intentGateway) { + intentGateway[config.stateMachineId] = config.contracts.intentGateway } }) diff --git a/sdk/packages/indexer/scripts/templates/solana-chain.yaml.hbs b/sdk/packages/indexer/scripts/templates/solana-chain.yaml.hbs new file mode 100644 index 000000000..8c3842ef0 --- /dev/null +++ b/sdk/packages/indexer/scripts/templates/solana-chain.yaml.hbs @@ -0,0 +1,77 @@ +{{> metadata}} +{{> network-config}} +dataSources: + - kind: solana/Runtime + startBlock: {{blockNumber}} + assets: + ismpHost: + # IDL ships with the host program. Path is relative to the project root. + file: ./idls/IsmpHost.idl.json + mapping: + file: ./dist/index.js + handlers: + # The PR 801 host has no Anchor `#[event]`s; everything is filtered as + # an instruction call. Discriminator strings are the IDL's camelCase + # function names. + - handler: handleSolanaStoreStateCommitmentInstruction + kind: solana/InstructionHandler + filter: + programId: '{{config.contracts.ismpHost}}' + discriminator: 'storeStateCommitment' + - handler: handleSolanaDispatchIncomingInstruction + kind: solana/InstructionHandler + filter: + programId: '{{config.contracts.ismpHost}}' + discriminator: 'dispatchIncoming' + - handler: handleSolanaVetoStateCommitmentInstruction + kind: solana/InstructionHandler + filter: + programId: '{{config.contracts.ismpHost}}' + discriminator: 'vetoStateCommitment' +{{#if config.contracts.tokenGateway}} + - kind: solana/Runtime + startBlock: {{blockNumber}} + assets: + tokenGateway: + file: ./idls/TokenGateway.idl.json + mapping: + file: ./dist/index.js + handlers: + - handler: handleSolanaAssetTeleportedEvent + kind: solana/InstructionHandler + filter: + programId: '{{config.contracts.tokenGateway}}' + discriminator: 'teleport' + - handler: handleSolanaAssetReceivedEvent + kind: solana/InstructionHandler + filter: + programId: '{{config.contracts.tokenGateway}}' + discriminator: 'onAccept' + - handler: handleSolanaAssetRefundedEvent + kind: solana/InstructionHandler + filter: + programId: '{{config.contracts.tokenGateway}}' + discriminator: 'onTimeout' +{{/if}} +{{#if config.contracts.intentGateway}} + - kind: solana/Runtime + startBlock: {{blockNumber}} + assets: + intentGateway: + file: ./idls/IntentGateway.idl.json + mapping: + file: ./dist/index.js + handlers: + - handler: handleSolanaOrderPlacedEvent + kind: solana/InstructionHandler + filter: + programId: '{{config.contracts.intentGateway}}' + discriminator: 'placeOrder' + - handler: handleSolanaOrderFilledEvent + kind: solana/InstructionHandler + filter: + programId: '{{config.contracts.intentGateway}}' + discriminator: 'fillOrder' +{{/if}} + +repository: 'https://github.com/polytope-labs/hyperbridge' diff --git a/sdk/packages/indexer/src/configs/index.ts b/sdk/packages/indexer/src/configs/index.ts index 71d8133f9..691334059 100644 --- a/sdk/packages/indexer/src/configs/index.ts +++ b/sdk/packages/indexer/src/configs/index.ts @@ -14,6 +14,26 @@ const evmContractsSchema = z.object({ tokenGateway: z.string().optional(), }) +// Solana program addresses (base58 program IDs) +const solanaContractsSchema = z.object({ + ismpHost: z.string().min(32, "Invalid Solana program id"), + tokenGateway: z.string().min(32, "Invalid Solana program id").optional(), + intentGateway: z.string().min(32, "Invalid Solana program id").optional(), +}) + +// Multi-provider quorum policy for Solana RPC reads. Each entry in `providers` +// is the env var key holding that operator's URL list, so the URLs themselves +// stay out of source. Threshold is the minimum number of byte-identical +// responses required before a read is accepted into a state commitment. +const solanaQuorumSchema = z + .object({ + providers: z.array(z.string().min(1)).min(2, "Quorum needs at least two providers"), + threshold: z.number().int().positive(), + }) + .refine((q) => q.threshold <= q.providers.length, { + message: "threshold cannot exceed providers length", + }) + // Base chain configuration schema const baseChainConfigSchema = z.object({ chainId: z.string(), @@ -32,6 +52,11 @@ export const schemaConfiguration = z.record( unfinalizedBlocks: z.boolean().optional(), contracts: evmContractsSchema.optional(), }), + baseChainConfigSchema.extend({ + type: z.literal("solana"), + contracts: solanaContractsSchema.optional(), + quorum: solanaQuorumSchema.optional(), + }), ]), ) diff --git a/sdk/packages/indexer/src/handlers/events/solana/dispatchIncoming.instruction.handler.ts b/sdk/packages/indexer/src/handlers/events/solana/dispatchIncoming.instruction.handler.ts new file mode 100644 index 000000000..e26131619 --- /dev/null +++ b/sdk/packages/indexer/src/handlers/events/solana/dispatchIncoming.instruction.handler.ts @@ -0,0 +1,52 @@ +import { Status } from "@/configs/src/types" +import { RequestService } from "@/services/request.service" +import { SolanaInstruction } from "@/types/subql-solana" +import { wrap } from "@/utils/event.utils" +import { bytesToHexLower, decodeDispatchIncomingParams } from "@/utils/solana.helpers" + +// `dispatchIncoming` is the host program's CPI entrypoint that delivers an +// inbound POST request from a source chain. The `commitment` parameter ties +// this delivery back to the request originated on the source chain, mirroring +// EVM's `PostRequestHandled(bytes32 commitment, address relayer)` event. +// +// instruction accounts list places the relayer at index 1 +// (handler_authority is index 0). +const RELAYER_ACCOUNT_INDEX = 1 + +export const handleSolanaDispatchIncomingInstruction = wrap( + async (instruction: SolanaInstruction): Promise => { + const params = decodeDispatchIncomingParams(instruction.data) + const commitment = bytesToHexLower(params.commitment) + + const blockTimeUnix = instruction.block.blockTime + if (blockTimeUnix === null) { + logger.warn( + `dispatchIncoming seen at slot ${instruction.block.slot} with no blockTime; skipping`, + ) + return + } + + const relayerAccount = instruction.accounts[RELAYER_ACCOUNT_INDEX] + if (!relayerAccount) { + logger.warn( + `dispatchIncoming missing relayer account at index ${RELAYER_ACCOUNT_INDEX}; skipping`, + ) + return + } + + logger.info( + `Solana dispatchIncoming: commitment=${commitment} relayer=${relayerAccount.pubkey} ` + + `slot=${instruction.block.slot} tx=${instruction.transaction.signature}`, + ) + + await RequestService.updateStatus({ + commitment, + chain: chainId, + blockNumber: instruction.block.slot.toString(), + blockHash: instruction.block.blockhash, + blockTimestamp: BigInt(blockTimeUnix), + status: Status.DESTINATION, + transactionHash: instruction.transaction.signature, + }) + }, +) diff --git a/sdk/packages/indexer/src/handlers/events/solana/storeStateCommitment.instruction.handler.ts b/sdk/packages/indexer/src/handlers/events/solana/storeStateCommitment.instruction.handler.ts new file mode 100644 index 000000000..06d74ad48 --- /dev/null +++ b/sdk/packages/indexer/src/handlers/events/solana/storeStateCommitment.instruction.handler.ts @@ -0,0 +1,57 @@ +import { StateMachineService } from "@/services/stateMachine.service" +import { SolanaInstruction } from "@/types/subql-solana" +import { wrap } from "@/utils/event.utils" +import { + bytesToHexLower, + decodeStoreStateCommitmentParams, + sourceStateMachineIdFor, +} from "@/utils/solana.helpers" + +// `chainId` is set by SubQuery to the indexer config's chain key, e.g. +// "solana-mainnet". The host's own state machine id +// `SOLANA_STATE_MACHINE = StateMachine::Substrate(*b"sola")`) is "SUBSTRATE-sola". +// We carry the indexer config chain key on the `chain` column to stay +// symmetric with the EVM and Substrate handlers, which use the same key. +export const handleSolanaStoreStateCommitmentInstruction = wrap( + async (instruction: SolanaInstruction): Promise => { + const params = decodeStoreStateCommitmentParams(instruction.data) + + const stateMachineId = sourceStateMachineIdFor(chainId, params.stateMachine) + const blockTimeUnix = instruction.block.blockTime + if (blockTimeUnix === null) { + logger.warn( + `storeStateCommitment seen at slot ${instruction.block.slot} with no blockTime; skipping`, + ) + return + } + + // `host_config.consensus_client_id` is `b"BEFY"` for the SP1 + // BEEFY client. The host accepts at most one consensus client id per + // deployment, so we hard-code rather than read from the host_config PDA + // on every event. If a deployment ever reconfigures, lift this to the + // indexer config alongside `ismpHost`. + const consensusStateId = "BEFY" + + logger.info( + `Solana storeStateCommitment: stateMachine=${stateMachineId} height=${params.height} ` + + `slot=${instruction.block.slot} tx=${instruction.transaction.signature}`, + ) + + await StateMachineService.createSolanaStateMachineUpdatedEvent( + { + transactionHash: instruction.transaction.signature, + transactionIndex: instruction.transactionIndex, + blockHash: instruction.block.blockhash, + blockNumber: instruction.block.slot, + timestamp: blockTimeUnix, + stateMachineId, + height: Number(params.height), + consensusStateId, + commitmentTimestamp: params.timestamp, + stateRootHex: bytesToHexLower(params.stateRoot), + overlayRootHex: bytesToHexLower(params.overlayRoot), + }, + chainId, + ) + }, +) diff --git a/sdk/packages/indexer/src/handlers/events/solana/vetoStateCommitment.instruction.handler.ts b/sdk/packages/indexer/src/handlers/events/solana/vetoStateCommitment.instruction.handler.ts new file mode 100644 index 000000000..3e6612fbd --- /dev/null +++ b/sdk/packages/indexer/src/handlers/events/solana/vetoStateCommitment.instruction.handler.ts @@ -0,0 +1,33 @@ +import { SolanaInstruction } from "@/types/subql-solana" +import { wrap } from "@/utils/event.utils" + +// `veto_state_commitment` is the admin's one-way veto on a previously stored +// state commitment. The host program flips `StateCommitment.vetoed = true` so +// `handle_post_requests` against it fails closed afterwards. +// +// We don't have a `vetoed` column on `StateMachineUpdateEvent` today, and +// adding one is a GraphQL schema change that requires a coordinated +// re-deployment. Until that lands, this handler logs the veto fact at WARN +// level so it surfaces in operator dashboards but does not silently update +// rows. See solana-indexer-plan.md section 5.5 for the design choice. +// +// The vetoed `state_machine` and `height` are not present in the instruction +// data — `VetoStateCommitment` takes only the StateCommitment account +// reference. Account is at index 2 (admin, host_config, +// state_commitment); we surface its pubkey for downstream correlation but do +// not attempt to derive (state_machine, height) from the seeds without +// `@solana/web3.js`. +const STATE_COMMITMENT_ACCOUNT_INDEX = 2 + +export const handleSolanaVetoStateCommitmentInstruction = wrap( + async (instruction: SolanaInstruction): Promise => { + const stateCommitmentAccount = instruction.accounts[STATE_COMMITMENT_ACCOUNT_INDEX] + const adminAccount = instruction.accounts[0] + + logger.warn( + `Solana state commitment vetoed: stateCommitmentPda=${stateCommitmentAccount?.pubkey ?? "unknown"} ` + + `admin=${adminAccount?.pubkey ?? "unknown"} ` + + `slot=${instruction.block.slot} tx=${instruction.transaction.signature}`, + ) + }, +) diff --git a/sdk/packages/indexer/src/mappings/mappingHandlers.ts b/sdk/packages/indexer/src/mappings/mappingHandlers.ts index b626eee98..9e2de09b4 100644 --- a/sdk/packages/indexer/src/mappings/mappingHandlers.ts +++ b/sdk/packages/indexer/src/mappings/mappingHandlers.ts @@ -46,6 +46,11 @@ export { handleSubstrateAssetTeleportedEvent } from "@/handlers/events/substrate export { handleSubstrateGetRequestHandledEvent } from "@/handlers/events/substrateChains/handleGetRequestHandledEvent.handler" export { handleSubstrateGetRequestTimeoutHandledEvent } from "@/handlers/events/substrateChains/handleGetRequestTimeoutHandledEvent.handler" +// Solana Host Handlers (inbound-only) +export { handleSolanaStoreStateCommitmentInstruction } from "@/handlers/events/solana/storeStateCommitment.instruction.handler" +export { handleSolanaDispatchIncomingInstruction } from "@/handlers/events/solana/dispatchIncoming.instruction.handler" +export { handleSolanaVetoStateCommitmentInstruction } from "@/handlers/events/solana/vetoStateCommitment.instruction.handler" + // Price Handlers export { handlePriceIndexing } from "@/handlers/events/price/handlePriceIndexing.event.handler" export { handleBridgeTokenSupplyIndexing } from "@/handlers/events/supply/handleBridgeTokenSupplyIndexing.event.handler" diff --git a/sdk/packages/indexer/src/services/stateMachine.service.ts b/sdk/packages/indexer/src/services/stateMachine.service.ts index 0ac6bfec8..bfdd5063c 100644 --- a/sdk/packages/indexer/src/services/stateMachine.service.ts +++ b/sdk/packages/indexer/src/services/stateMachine.service.ts @@ -2,6 +2,24 @@ import { StateMachineUpdateEvent } from "@/configs/src/types" import { fetchStateCommitmentsEVM, fetchStateCommitmentsSubstrate, getStateId } from "@/utils/state-machine.helper" import { timestampToDate } from "@/utils/date.helpers" +// Solana inputs already carry the commitment in the instruction params, so the +// service receives the full commitment instead of fetching it. Keeps the EVM +// and Substrate creators (which do fetch) and this one symmetric at the call +// site even though the underlying fetch is elided. +export interface ICreateSolanaStateMachineUpdatedEventArgs { + stateMachineId: string + height: number + blockHash: string + blockNumber: number + transactionHash: string + transactionIndex: number + timestamp: number + consensusStateId: string + commitmentTimestamp: bigint + stateRootHex: string + overlayRootHex: string +} + // Arguments to functions that create StateMachineUpdated events export interface ICreateStateMachineUpdatedEventArgs { stateMachineId: string @@ -122,6 +140,53 @@ export class StateMachineService { await event.save() } + /** + * Create a Solana host StateMachineUpdated event entity. Unlike the EVM and + * Substrate creators, the commitment values come in via the args (decoded + * from the host program's `storeStateCommitment` instruction params) + * because the params already carry state_root, overlay_root, and timestamp + * on the wire — no post-hoc account fetch needed for the hot path. + */ + static async createSolanaStateMachineUpdatedEvent( + args: ICreateSolanaStateMachineUpdatedEventArgs, + chain: string, + ): Promise { + const { + blockHash, + blockNumber, + transactionHash, + transactionIndex, + timestamp, + stateMachineId, + height, + consensusStateId, + commitmentTimestamp, + } = args + + logger.info( + `Creating Solana StateMachineUpdated Event: ${JSON.stringify({ + ...args, + commitmentTimestamp: commitmentTimestamp.toString(), + })}`, + ) + logger.info(`Using Consensus State ID: ${consensusStateId}`) + + const event = StateMachineUpdateEvent.create({ + id: `${chain}_${transactionHash}_${stateMachineId}_${height}`, + stateMachineId, + height, + chain, + transactionHash, + transactionIndex: Number(transactionIndex), + blockHash, + blockNumber: Number(blockNumber), + commitmentTimestamp, + createdAt: timestampToDate(timestamp), + }) + + await event.save() + } + /** * Get updates by state machine ID */ diff --git a/sdk/packages/indexer/src/types/subql-solana.ts b/sdk/packages/indexer/src/types/subql-solana.ts new file mode 100644 index 000000000..6a636ce4e --- /dev/null +++ b/sdk/packages/indexer/src/types/subql-solana.ts @@ -0,0 +1,34 @@ +// Local stand-in for the upstream `@subql/types-solana` payload shapes. +// Replace these imports with `@subql/types-solana` once that dep is added +// to package.json and `pnpm install` runs. + +export interface SolanaBlock { + blockHeight: number + slot: number + blockTime: number | null + blockhash: string + parentSlot: number +} + +export interface SolanaTransaction { + signature: string + slot: number + err: unknown +} + +export interface SolanaAccount { + pubkey: string + isSigner: boolean + isWritable: boolean +} + +export interface SolanaInstruction { + programId: string + // 8-byte discriminator + Borsh-encoded params. + data: Uint8Array + accounts: SolanaAccount[] + block: SolanaBlock + transaction: SolanaTransaction + transactionIndex: number + instructionIndex: number +}