diff --git a/docs/content/developers/sdk/getting-started.mdx b/docs/content/developers/sdk/getting-started.mdx index 1aabcb520..2b73933f8 100644 --- a/docs/content/developers/sdk/getting-started.mdx +++ b/docs/content/developers/sdk/getting-started.mdx @@ -66,8 +66,59 @@ const hyperbridge = await SubstrateChain.connect({ }) ``` +**Polkadot Hub chain (EVM on Substrate / Revive):** + +Use `PolkadotHubChain` when the execution environment is the EVM hosted on a Substrate chain (Revive). It wraps an internal [`EvmChain`](/developers/sdk/api/evm-chain) for RPC reads, contract calls, and transaction helpers, and uses a **Substrate JSON-RPC** endpoint to build storage proofs (`queryProof`, `queryStateProof`) from the main trie and Revive child tries—the same layout the relayer uses for Substrate-EVM. + +`PolkadotHubChain.create(evmRpcUrl, substrateRpcUrl, bundlerUrl?)` mirrors [`EvmChain.create`](/developers/sdk/api/evm-chain): it resolves chain id and `IsmpHost` from known deployments, attaches your Substrate endpoint for proofs, and optionally forwards an ERC-4337 **bundler URL** as the third argument (same semantics as `EvmChain.create`). + +```typescript lineNumbers title="client.ts" icon=typescript +import { PolkadotHubChain } from "@hyperbridge/sdk" + +const evmRpc = "https://your-evm-endpoint.example" +const substrateRpc = "https://your-substrate-endpoint.example" + +// Without a bundler (two arguments) +const polkadotHub = await PolkadotHubChain.create(evmRpc, substrateRpc) + +// With an ERC-4337 bundler (optional third argument, same as EvmChain.create) +const polkadotHubWithBundler = await PolkadotHubChain.create( + evmRpc, + substrateRpc, + "https://your-bundler.example", +) +``` + +Or pass full parameters when you already know chain id and host: + +```typescript lineNumbers title="client.ts" icon=typescript +import { PolkadotHubChain } from "@hyperbridge/sdk" + +const polkadotHub = PolkadotHubChain.fromParams({ + chainId: 420420417, + rpcUrl: "https://your-evm-endpoint.example", + host: "0xYourIsmpHostAddress", + substrateRpcUrl: "https://your-substrate-endpoint.example", + // Optional — defaults exist for some known chain IDs (same as EvmChain) + consensusStateId: "PAS0", + // Optional — ERC-4337 bundler, same as EvmChain + // bundlerUrl: "https://...", +}) +``` + +| Field | Description | +|-------|-------------| +| `chainId`, `rpcUrl`, `host` | Same as [`EvmChain`](/developers/sdk/api/evm-chain): EVM chain id, JSON-RPC URL, and `IsmpHost` address. | +| `substrateRpcUrl` | Substrate node RPC (HTTP or WebSocket). WebSocket URLs are converted to HTTP for proof RPCs. | +| `consensusStateId` | Optional Hyperbridge consensus state id for this chain (mirrors `EvmChain`). | +| `bundlerUrl` | Optional account-abstraction bundler (mirrors `EvmChain`). | + +The instance’s `config` includes every `IEvmConfig` field plus `substrateRpcUrl` (`IPolkadotHubConfig`). + +**Proofs:** `queryProof` **must** be called with an explicit finalized (or chosen) block height as `at`. Other `IChain` methods delegate to the inner EVM client, same pattern as other chain types. + For detailed configuration options and supported chains, see: -- [EvmChain API Reference](/developers/sdk/api/evm-chain) +- [EvmChain API Reference](/developers/sdk/api/evm-chain) (shared EVM surface for Polkadot Hub) - [SubstrateChain API Reference](/developers/sdk/api/substrate-chain) --- @@ -111,9 +162,9 @@ const indexer = new IndexerClient({ |-----------|------|-------------| | `queryClient` | `QueryClient` | Query client for the Hyperbridge indexer | | `pollInterval` | `number` | Polling interval in milliseconds (default: 1000) | -| `source` | `IChain` | Source chain instance | +| `source` | `IChain` | Source chain instance (`EvmChain`, `SubstrateChain`, `PolkadotHubChain`, …) | | `dest` | `IChain` | Destination chain instance | -| `hyperbridge` | `IChain` | Hyperbridge chain instance | +| `hyperbridge` | `IChain` | Hyperbridge chain instance (often `SubstrateChain`) | For complete API documentation and usage examples, see the [IndexerClient API Reference](/developers/sdk/api/indexer-client). @@ -180,7 +231,7 @@ main().catch(console.error) Now that you have the SDK set up, explore the detailed documentation: - **[IndexerClient API](/developers/sdk/api/indexer-client)** - Complete API reference with all methods and usage examples -- **[EvmChain API](/developers/sdk/api/evm-chain)** - EVM chain methods and configuration +- **[EvmChain API](/developers/sdk/api/evm-chain)** - EVM chain methods and configuration (also applies to the EVM layer of `PolkadotHubChain`) - **[SubstrateChain API](/developers/sdk/api/substrate-chain)** - Substrate chain methods and configuration - **[Tracking Requests](/developers/sdk/tracking/post-requests)** - Monitor cross-chain message statuses - **[Vite Integration](/developers/sdk/vite-integration)** - Detailed Vite plugin configuration and framework examples diff --git a/sdk/packages/sdk/src/chain.ts b/sdk/packages/sdk/src/chain.ts index 0f068de61..5835802f8 100644 --- a/sdk/packages/sdk/src/chain.ts +++ b/sdk/packages/sdk/src/chain.ts @@ -5,6 +5,7 @@ import type { IEvmConfig, IGetRequest, IMessage, + IPolkadotHubConfig, IPostRequest, ISubstrateConfig, StateMachineHeight, @@ -16,6 +17,7 @@ export * from "@/chains/evm" export * from "@/chains/substrate" export * from "@/chains/intentsCoprocessor" export * from "@/chains/tron" +export * from "@/chains/polkadotHub" /** * Type representing an ISMP message. @@ -139,7 +141,7 @@ export interface IChain { /** * Returns the configuration for this chain */ - get config(): IEvmConfig | ISubstrateConfig + get config(): IEvmConfig | ISubstrateConfig | IPolkadotHubConfig /* * Returns the current timestamp of the chain in seconds. @@ -189,7 +191,7 @@ export interface IChain { } /** - * Interface for EVM-compatible chains (EVM and Tron). + * Interface for EVM-compatible chains (EVM, Tron, Polkadot Hub). * Extends IChain with methods required by IntentGatewayV2 and other EVM-specific protocols. */ export interface IEvmChain extends IChain { diff --git a/sdk/packages/sdk/src/chains/evm.ts b/sdk/packages/sdk/src/chains/evm.ts index aeb0df30a..343eec7a2 100644 --- a/sdk/packages/sdk/src/chains/evm.ts +++ b/sdk/packages/sdk/src/chains/evm.ts @@ -143,6 +143,7 @@ export class EvmChain implements IChain { 100: "GNO0", // Gnosis 10200: "GNO0", // Gnosis Chiado 420420417: "PAS0", // Polkadot Asset Hub (Paseo) + 420420419: "DOT0", // Polkadot Asset Hub (Polkadot) } // Set default consensusStateId if not provided @@ -818,7 +819,7 @@ export function requestCommitmentKey(key: Hex): { slot1: Hex; slot2: Hex } { } } -function responseCommitmentKey(key: Hex): Hex { +export function responseCommitmentKey(key: Hex): Hex { // First derive the map key const keyBytes = hexToBytes(key) const slot = RESPONSE_COMMITMENTS_SLOT diff --git a/sdk/packages/sdk/src/chains/polkadotHub.ts b/sdk/packages/sdk/src/chains/polkadotHub.ts new file mode 100644 index 000000000..9e19113f1 --- /dev/null +++ b/sdk/packages/sdk/src/chains/polkadotHub.ts @@ -0,0 +1,325 @@ +import type { PublicClient, TransactionReceipt } from "viem" +import { bytesToHex, getAddress, hexToBytes } from "viem" +import { u8aConcat } from "@polkadot/util" +import { blake2AsU8a, xxhashAsU8a } from "@polkadot/util-crypto" + +import type { IChain, IIsmpMessage } from "@/chain" +import { EvmChain, requestCommitmentKey, responseCommitmentKey, type EvmChainParams } from "@/chains/evm" +import type { + HexString, + IGetRequest, + IMessage, + IPolkadotHubConfig, + IPostRequest, + StateMachineHeight, + StateMachineIdParams, +} from "@/types" +import { replaceWebsocketWithHttp } from "@/utils" +import { decodeReviveContractTrieId } from "@/utils/reviveAccount" +import { encodeSubstrateEvmProofBytes } from "@/utils/substrate" + +/** Substrate default child trie prefix (`ChildInfo::new_default`). */ +const DEFAULT_CHILD_STORAGE_PREFIX = new TextEncoder().encode(":child_storage:default:") + +/** + * Full chain params: EVM JSON-RPC + Ismp host plus a Substrate JSON-RPC URL for proof queries. + */ +export type PolkadotHubChainParams = EvmChainParams & { + substrateRpcUrl: string +} + +interface ReadProofRpc { + at?: string + proof: string[] +} + +class SubstrateHttpRpc { + constructor(private readonly url: string) {} + + async call(method: string, params: unknown[] = []): Promise { + const body = JSON.stringify({ + jsonrpc: "2.0", + id: Date.now(), + method, + params, + }) + + const response = await fetch(this.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }) + + if (!response.ok) { + throw new Error(`Substrate RPC HTTP error: ${response.status}`) + } + + const json = (await response.json()) as { result?: unknown; error?: { message: string } } + if (json.error) { + throw new Error(`Substrate RPC error: ${json.error.message}`) + } + + return json.result + } +} + +function contractInfoKey(address20: Uint8Array): Uint8Array { + const key = new Uint8Array(16 + 16 + 20) + key.set(xxhashAsU8a("Revive", 128), 0) + key.set(xxhashAsU8a("AccountInfoOf", 128), 16) + key.set(address20, 32) + return key +} + +function childPrefixedStorageKey(trieId: Uint8Array): Uint8Array { + return u8aConcat(DEFAULT_CHILD_STORAGE_PREFIX, trieId) +} + +function storageKeyForSlot(slot32: Uint8Array): Uint8Array { + return blake2AsU8a(slot32, 256) +} + +function hexKey(k: Uint8Array): HexString { + return bytesToHex(k) as HexString +} + +/** + * Polkadot Hub (EVM on Substrate / Revive): EVM RPC + host for reads and txs; Substrate RPC for + * child-trie proofs aligned with `tesseract/messaging/substrate-evm` (`query_requests_proof` / + * `query_state_proof`). + */ +export class PolkadotHubChain implements IChain { + private readonly evm: EvmChain + private readonly substrateRpc: SubstrateHttpRpc + + static fromParams(params: PolkadotHubChainParams): PolkadotHubChain { + const { substrateRpcUrl, ...evmParams } = params + const evm = EvmChain.fromParams(evmParams) + return new PolkadotHubChain(params, evm) + } + + /** + * Creates a `PolkadotHubChain` by auto-detecting the EVM chain ID and `IsmpHost` address via + * {@link EvmChain.create}, plus a Substrate RPC URL for Revive child-trie proofs. + * + * @param evmRpcUrl - HTTP(S) JSON-RPC URL of the EVM (Revive) node + * @param substrateRpcUrl - Substrate node RPC (HTTP or WebSocket) for proof queries + * @param bundlerUrl - Optional ERC-4337 bundler URL (forwarded to `EvmChain.create`) + */ + static async create(evmRpcUrl: string, substrateRpcUrl: string, bundlerUrl?: string): Promise { + const evm = await EvmChain.create(evmRpcUrl, bundlerUrl) + const chainId = Number.parseInt(evm.config.stateMachineId.replace(/^EVM-/, ""), 10) + if (!Number.isFinite(chainId)) { + throw new Error(`Unexpected EVM stateMachineId: ${evm.config.stateMachineId}`) + } + const params: PolkadotHubChainParams = { + chainId, + rpcUrl: evm.config.rpcUrl, + host: evm.config.host, + consensusStateId: evm.config.consensusStateId, + bundlerUrl: evm.bundlerUrl, + substrateRpcUrl, + } + return new PolkadotHubChain(params, evm) + } + + private constructor( + private readonly params: PolkadotHubChainParams, + evm: EvmChain, + ) { + this.evm = evm + this.substrateRpc = new SubstrateHttpRpc(replaceWebsocketWithHttp(params.substrateRpcUrl)) + } + + get client(): PublicClient { + return this.evm.client + } + + get host(): HexString { + return this.evm.host + } + + get bundlerUrl(): string | undefined { + return this.evm.bundlerUrl + } + + get configService() { + return this.evm.configService + } + + get config(): IPolkadotHubConfig { + return { + ...this.evm.config, + substrateRpcUrl: this.params.substrateRpcUrl, + } + } + + private hostAddress20(): Uint8Array { + return hexToBytes(getAddress(this.evm.host)) + } + + private async fetchCombinedProof(at: bigint, queries: Map): Promise { + const height = Number(at) + if (!Number.isSafeInteger(height) || height < 0) { + throw new Error("Block height must be a non-negative safe integer for Substrate RPC") + } + + const blockHash = (await this.substrateRpc.call("chain_getBlockHash", [height])) as string | null + if (!blockHash) { + throw new Error(`Block hash not found for height ${height}`) + } + + const mainKeys: HexString[] = [] + const childInfoByAddr = new Map() + const contractEntries = [...queries.entries()] + + for (const [addr20] of contractEntries) { + const infoKey = contractInfoKey(addr20) + const storageHex = (await this.substrateRpc.call("state_getStorage", [hexKey(infoKey), blockHash])) as + | string + | null + if (!storageHex) { + throw new Error(`Revive AccountInfo not found for contract ${hexKey(addr20)}`) + } + const trieId = decodeReviveContractTrieId(hexToBytes(storageHex as HexString)) + const prefixed = childPrefixedStorageKey(trieId) + mainKeys.push(hexKey(infoKey)) + mainKeys.push(hexKey(prefixed)) + childInfoByAddr.set(hexKey(addr20), { trieId, prefixed }) + } + + const mainRead = (await this.substrateRpc.call("state_getReadProof", [mainKeys, blockHash])) as ReadProofRpc + const mainProofBytes = mainRead.proof.map((p) => hexToBytes(p as HexString)) + + const storageProofEncoded = new Map() + + for (const [addr20, innerKeys] of contractEntries) { + const addrHex = hexKey(addr20) + const info = childInfoByAddr.get(addrHex) + if (!info) { + throw new Error("Internal error: missing child info for contract") + } + const childKeysHex = innerKeys.map((k) => hexKey(k)) + const childRead = (await this.substrateRpc.call("state_getChildReadProof", [ + hexKey(info.prefixed), + childKeysHex, + blockHash, + ])) as ReadProofRpc + + storageProofEncoded.set( + addr20, + childRead.proof.map((p) => hexToBytes(p as HexString)), + ) + } + + const encoded = encodeSubstrateEvmProofBytes({ + mainProof: mainProofBytes, + storageProof: storageProofEncoded, + }) + return bytesToHex(encoded) as HexString + } + + timestamp(): Promise { + return this.evm.timestamp() + } + + requestReceiptKey(commitment: HexString): HexString { + return this.evm.requestReceiptKey(commitment) + } + + queryRequestReceipt(commitment: HexString): Promise { + return this.evm.queryRequestReceipt(commitment) + } + + async queryProof(message: IMessage, _counterparty: string, at?: bigint): Promise { + if (at === undefined) { + throw new Error("PolkadotHubChain.queryProof requires an explicit block height `at`") + } + const host = this.hostAddress20() + const storageKeys = + "Requests" in message + ? message.Requests.map((c) => storageKeyForSlot(hexToBytes(requestCommitmentKey(c).slot1))) + : message.Responses.map((c) => storageKeyForSlot(hexToBytes(responseCommitmentKey(c)))) + + const q = new Map() + q.set(host, storageKeys) + return this.fetchCombinedProof(at, q) + } + + async queryStateProof(at: bigint, keys: HexString[], _address?: HexString): Promise { + const keyBytes = keys.map((k) => hexToBytes(k)) + const host = this.hostAddress20() + + if (keyBytes.every((k) => k.length === 32)) { + const storageKeys = keyBytes.map((slot) => storageKeyForSlot(slot)) + const q = new Map() + q.set(host, storageKeys) + return this.fetchCombinedProof(at, q) + } + + if (keyBytes.every((k) => k.length === 52)) { + const groups = new Map() + for (const full of keyBytes) { + const addr = full.subarray(0, 20) + const slot = full.subarray(20, 52) + const h = hexKey(addr) + const arr = groups.get(h) ?? [] + arr.push(storageKeyForSlot(slot)) + groups.set(h, arr) + } + const q = new Map() + for (const [addrHex, sks] of groups) { + q.set(hexToBytes(addrHex as HexString), sks) + } + return this.fetchCombinedProof(at, q) + } + + throw new Error( + "PolkadotHubChain.queryStateProof: keys must be either all 32-byte ISMP slots or all 52-byte (20-byte address + 32-byte slot) entries", + ) + } + + encode(message: IIsmpMessage): HexString { + return this.evm.encode(message) + } + + latestStateMachineHeight(stateMachineId: StateMachineIdParams): Promise { + return this.evm.latestStateMachineHeight(stateMachineId) + } + + challengePeriod(stateMachineId: StateMachineIdParams): Promise { + return this.evm.challengePeriod(stateMachineId) + } + + stateMachineUpdateTime(stateMachineHeight: StateMachineHeight): Promise { + return this.evm.stateMachineUpdateTime(stateMachineHeight) + } + + getHostNonce(): Promise { + return this.evm.getHostNonce() + } + + quoteNative(request: IPostRequest | IGetRequest, fee: bigint): Promise { + return this.evm.quoteNative(request, fee) + } + + getFeeTokenWithDecimals(): Promise<{ address: HexString; decimals: number }> { + return this.evm.getFeeTokenWithDecimals() + } + + getPlaceOrderCalldata(txHash: string, intentGatewayAddress: string): Promise { + return this.evm.getPlaceOrderCalldata(txHash, intentGatewayAddress) + } + + estimateGas(request: IPostRequest): Promise<{ gas: bigint; postRequestCalldata: HexString }> { + return this.evm.estimateGas(request) + } + + broadcastTransaction(signedTransaction: HexString): Promise { + return this.evm.broadcastTransaction(signedTransaction) + } + + getTransactionReceipt(hash: HexString): Promise { + return this.evm.getTransactionReceipt(hash) + } +} diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index 0da5b04c0..d91e2da60 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -15,9 +15,9 @@ export type HexString = `0x${string}` export interface IConfig { // confuration object for the source chain - source: IEvmConfig | ISubstrateConfig + source: IEvmConfig | ISubstrateConfig | IPolkadotHubConfig // confuration object for the destination chain - dest: IEvmConfig | ISubstrateConfig + dest: IEvmConfig | ISubstrateConfig | IPolkadotHubConfig // confuration object for hyperbridge hyperbridge: IHyperbridgeConfig // Flag to enable tracing console logs @@ -35,6 +35,14 @@ export interface IEvmConfig { consensusStateId: string } +/** + * EVM-on-Substrate (e.g. Polkadot Hub) — same as {@link IEvmConfig} plus a Substrate node RPC URL + * used for `state_getReadProof` / `state_getChildReadProof` (Revive child trie proofs). + */ +export interface IPolkadotHubConfig extends IEvmConfig { + substrateRpcUrl: string +} + export interface ISubstrateConfig { // rpc url of the chain wsUrl: string diff --git a/sdk/packages/sdk/src/utils/reviveAccount.ts b/sdk/packages/sdk/src/utils/reviveAccount.ts new file mode 100644 index 000000000..30612b997 --- /dev/null +++ b/sdk/packages/sdk/src/utils/reviveAccount.ts @@ -0,0 +1,29 @@ +import { Enum, Struct, Vector, u8 } from "scale-ts" + +/** + * Revive pallet account layout for child-trie proofs — mirrors + * `ContractInfo`, `AccountType`, and `AccountInfo` in + * `modules/ismp/state-machines/evm/src/substrate_evm.rs`. + */ +export const ReviveContractInfo = Struct({ + trie_id: Vector(u8), +}) + +export const ReviveAccountType = Enum({ + Contract: ReviveContractInfo, +}) + +export const ReviveAccountInfo = Struct({ + account_type: ReviveAccountType, +}) + +/** + * Decode SCALE-encoded `AccountInfo` and return `ContractInfo::trie_id`. + * Fails at decode time if bytes are not a valid `AccountInfo` or not `AccountType::Contract`. + */ +export function decodeReviveContractTrieId(accountData: Uint8Array): Uint8Array { + const { + account_type: { value }, + } = ReviveAccountInfo.dec(accountData) + return value.trie_id +} diff --git a/sdk/packages/sdk/src/utils/substrate.ts b/sdk/packages/sdk/src/utils/substrate.ts index 28bef7681..f24507cfe 100644 --- a/sdk/packages/sdk/src/utils/substrate.ts +++ b/sdk/packages/sdk/src/utils/substrate.ts @@ -1,4 +1,4 @@ -import { Struct, Vector, u8, u64, Tuple, Enum, _void, u32, Option } from "scale-ts" +import { type CodecType, Enum, Option, Struct, Tuple, Vector, _void, u32, u64, u8 } from "scale-ts" export type IStateMachine = | { @@ -35,6 +35,27 @@ export const EvmStateProof = Struct({ storageProof: Vector(Tuple(Vector(u8), Vector(Vector(u8)))), }) +/** + * Main trie proof (AccountInfo + child root) plus per-contract child trie proofs for Revive / EVM-on-Substrate. + * Matches `SubstrateEvmProof` in `modules/ismp/state-machines/evm/src/types.rs` (`storage_proof` is a + * `BTreeMap, Vec>>` — same layout as a length-prefixed list of `(key, value)` pairs). + */ +export const SubstrateEvmProof = Struct({ + main_proof: Vector(Vector(u8)), + storage_proof: Vector(Tuple(Vector(u8), Vector(Vector(u8)))), +}) + +/** Encode proof for Polkadot Hub / Revive; `scale-ts` types `Vector(u8)` as `number[]` but `Uint8Array` is valid at runtime. */ +export function encodeSubstrateEvmProofBytes(params: { + mainProof: Uint8Array[] + storageProof: Map +}): Uint8Array { + return SubstrateEvmProof.enc({ + main_proof: params.mainProof, + storage_proof: Array.from(params.storageProof.entries()), + } as unknown as CodecType) +} + export const SubstrateHashing = Enum({ /* For chains that use keccak as their hashing algo */ Keccak: _void,