Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions sdk/packages/indexer/scripts/generate-chain-yamls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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
}
})

Expand All @@ -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
}
})

Expand All @@ -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
}
})

Expand Down
77 changes: 77 additions & 0 deletions sdk/packages/indexer/scripts/templates/solana-chain.yaml.hbs
Original file line number Diff line number Diff line change
@@ -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'
25 changes: 25 additions & 0 deletions sdk/packages/indexer/src/configs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
}),
]),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> => {
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,
})
},
)
Original file line number Diff line number Diff line change
@@ -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<void> => {
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,
)
},
)
Original file line number Diff line number Diff line change
@@ -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<void> => {
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}`,
)
},
)
5 changes: 5 additions & 0 deletions sdk/packages/indexer/src/mappings/mappingHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading