diff --git a/.changeset/fix-devnet-test-assertions.md b/.changeset/fix-devnet-test-assertions.md new file mode 100644 index 0000000..0f0f035 --- /dev/null +++ b/.changeset/fix-devnet-test-assertions.md @@ -0,0 +1,5 @@ +--- +"@solana/web3-compat": patch +--- + +Fix flaky devnet integration test assertions for Base58 encoded string lengths diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e18416..1c9b0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,6 +197,47 @@ jobs: - name: Check bundle size limits run: pnpm size-limit + devnet-integration: + name: Devnet Integration Tests + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.20.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Cache Turbo + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-devnet-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-devnet- + ${{ runner.os }}-turbo- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Run devnet integration tests + env: + TEST_PRIVATE_KEY: ${{ secrets.TEST_PRIVATE_KEY }} + run: pnpm vitest run packages/web3-compat/test/devnet-integration.test.ts + changeset: name: Require Changeset runs-on: ubuntu-latest diff --git a/packages/client/README.md b/packages/client/README.md index b832c4b..02e1336 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -3,8 +3,6 @@ Framework-agnostic building blocks for Solana RPC, subscriptions, wallets, and transactions. Works in any runtime (React, Svelte, API routes, workers, etc.). -> **Status:** Experimental – expect rapid iteration. - ## Install ```bash diff --git a/packages/web3-compat/README.md b/packages/web3-compat/README.md index 69d1e1e..14c054c 100644 --- a/packages/web3-compat/README.md +++ b/packages/web3-compat/README.md @@ -1,168 +1,219 @@ -# `@solana/web3-compat` +# @solana/web3-compat -Phase 0 of a backwards‑compatible surface that lets existing `@solana/web3.js` -code run on top of Kit primitives. +Drop-in replacement for `@solana/web3.js`. Same API, powered by `@solana/kit`. -This package is designed to help migrate from web3.js to Kit. +## Install -The goal of this release is **zero breaking changes** for applications that only -touch the subset of web3.js APIs listed below. There will be future releases that slowly -implement breaking changes as they move over to Kit primitives and intuitions. +```bash +npm install @solana/web3-compat +``` -## Migrating from `@solana/web3.js` +## Quickstart -The migration process is straightforward and can be done incrementally: +Swap the import: -### Install the compatibility package +```ts +// Before +import { Connection, PublicKey, Keypair } from "@solana/web3.js"; -```bash -pnpm add @solana/web3-compat +// After +import { Connection, PublicKey, Keypair } from "@solana/web3-compat"; ``` -Make sure you also have the required Kit peer dependencies: +That's it. Your existing code works as-is. -```bash -pnpm add @solana/kit @solana/client -``` +## Common Solana flows (copy/paste) -### Update your imports +### Get balance -Replace your web3.js imports with the compatibility layer. Both import styles are supported: +```ts +import { Connection, PublicKey } from "@solana/web3-compat"; -#### Named imports (TypeScript/ES6 style) +const connection = new Connection("https://api.devnet.solana.com"); +const balance = await connection.getBalance( + new PublicKey("Fg6PaFpoGXkYsidMpWFKfwtz6DhFVyG4dL1x8kj7ZJup") +); +console.log(`Balance: ${balance / 1e9} SOL`); +``` -**Before:** +### Get account info ```ts -import { - Connection, - Keypair, - PublicKey, - SystemProgram, - Transaction, - sendAndConfirmTransaction, -} from "@solana/web3.js"; +const accountInfo = await connection.getAccountInfo(publicKey); +if (accountInfo) { + console.log("Lamports:", accountInfo.lamports); + console.log("Owner:", accountInfo.owner.toBase58()); + console.log("Data length:", accountInfo.data.length); +} ``` -**After:** +### Get latest blockhash + +```ts +const { blockhash, lastValidBlockHeight } = + await connection.getLatestBlockhash(); +console.log("Blockhash:", blockhash); +``` + +### Send transaction ```ts import { Connection, Keypair, - PublicKey, SystemProgram, Transaction, - sendAndConfirmTransaction, } from "@solana/web3-compat"; -``` -#### Namespace imports +const connection = new Connection("https://api.devnet.solana.com"); +const sender = Keypair.generate(); +const recipient = Keypair.generate(); -**Before:** +const { blockhash } = await connection.getLatestBlockhash(); -```js -const solanaWeb3 = require("@solana/web3.js"); -const connection = new solanaWeb3.Connection( - "https://api.mainnet-beta.solana.com" +const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: sender.publicKey, + toPubkey: recipient.publicKey, + lamports: 100_000_000, // 0.1 SOL + }) ); +transaction.recentBlockhash = blockhash; +transaction.feePayer = sender.publicKey; +transaction.sign(sender); + +const signature = await connection.sendRawTransaction(transaction.serialize()); +console.log("Signature:", signature); ``` -**After:** +### Confirm transaction -```js -const solanaWeb3 = require("@solana/web3-compat"); -const connection = new solanaWeb3.Connection( - "https://api.mainnet-beta.solana.com" +```ts +// Simple confirmation +const result = await connection.confirmTransaction(signature, "confirmed"); +console.log("Confirmed:", result.value?.err === null); + +// With blockhash strategy +const result = await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + "confirmed" ); ``` -Or with ES6 modules: +### Get program accounts ```ts -import * as solanaWeb3 from "@solana/web3-compat"; +const TOKEN_PROGRAM = new PublicKey( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" +); + +const accounts = await connection.getProgramAccounts(TOKEN_PROGRAM, { + filters: [{ dataSize: 165 }], // Token account size +}); + +accounts.forEach(({ pubkey, account }) => { + console.log("Address:", pubkey.toBase58()); + console.log("Lamports:", account.lamports); +}); ``` -### (Optional): Leverage Kit features +### Request airdrop -You can gradually adopt Kit primitives alongside the compatibility layer using bridge helpers: +```ts +const signature = await connection.requestAirdrop( + publicKey, + 1_000_000_000 // 1 SOL +); +await connection.confirmTransaction(signature); +console.log("Airdrop confirmed"); +``` + +### Simulate transaction ```ts -import { toAddress, toPublicKey, toKitSigner } from "@solana/web3-compat"; +const simulation = await connection.simulateTransaction(transaction); +console.log("Logs:", simulation.value.logs); +console.log("Error:", simulation.value.err); +``` -// Convert between web3.js and Kit types -const web3PublicKey = new PublicKey("11111111111111111111111111111111"); -const kitAddress = toAddress(web3PublicKey); +### Get token accounts -// Convert back if needed -const backToWeb3 = toPublicKey(kitAddress); +```ts +const tokenAccounts = await connection.getTokenAccountsByOwner(ownerPublicKey, { + programId: TOKEN_PROGRAM, +}); + +tokenAccounts.value.forEach(({ pubkey, account }) => { + console.log("Token account:", pubkey.toBase58()); +}); ``` -### Migration checklist - -- [ ] Install `@solana/web3-compat` and Kit dependencies -- [ ] Update import statements from `@solana/web3.js` to `@solana/web3-compat` -- [ ] Test your application -- [ ] Keep legacy `@solana/web3.js` for any unimplemented methods (see limitations below) - -## Implemented in Phase 0 - -- `Connection` backed by Kit with support for: - - `getLatestBlockhash` - - `getBalance` - - `getAccountInfo` - - `getProgramAccounts` - - `getSignatureStatuses` - - `sendRawTransaction` - - `confirmTransaction` - - `simulateTransaction` -- Bridge helpers re-exported from `@solana/compat`: - - `toAddress`, `toPublicKey`, `toWeb3Instruction`, `toKitSigner` -- Programs: - - `SystemProgram.transfer` (manual u8/u64 little‑endian encoding) -- Utilities: - - `LAMPORTS_PER_SOL` - - `compileFromCompat` - - `sendAndConfirmTransaction` -- Re‑exports of all Web3 primitives (`PublicKey`, `Keypair`, `Transaction`, - `VersionedTransaction`, `TransactionInstruction`, etc) - -## Running package locally - -### Building the package +### WebSocket subscriptions -```bash -# Build TypeScript definitions -pnpm --filter @solana/web3-compat build +```ts +// Watch account changes +const subscriptionId = connection.onAccountChange(publicKey, (accountInfo) => { + console.log("Account updated:", accountInfo.lamports); +}); + +// Later: unsubscribe +await connection.removeAccountChangeListener(subscriptionId); + +// Watch slot changes +const slotSubscription = connection.onSlotChange((slotInfo) => { + console.log("New slot:", slotInfo.slot); +}); +``` + +## Migration to @solana/client + +Access the underlying `SolanaClient` for gradual migration: + +```ts +import { Connection } from "@solana/web3-compat"; + +const connection = new Connection("https://api.devnet.solana.com"); -# Or build components separately -pnpm --filter @solana/web3-compat compile:js -pnpm --filter @solana/web3-compat compile:typedefs +// Get the SolanaClient instance +const client = connection.client; + +// Use @solana/client features +await client.actions.connectWallet("phantom"); +const wallet = client.store.getState().wallet; +if (wallet.status === "connected") { + console.log("Connected:", wallet.session.account.address); +} ``` -### Running tests +## Bridge helpers -```bash -# Run all tests -pnpm --filter @solana/web3-compat test +Convert between web3.js and Kit types: + +```ts +import { + toAddress, + toPublicKey, + toKitSigner, + toWeb3Instruction, +} from "@solana/web3-compat"; + +// web3.js PublicKey → Kit Address +const address = toAddress(publicKey); + +// Kit Address → web3.js PublicKey +const pubkey = toPublicKey(address); + +// web3.js Keypair → Kit Signer +const signer = toKitSigner(keypair); + +// Kit Instruction → web3.js TransactionInstruction +const instruction = toWeb3Instruction(kitInstruction); ``` -## Known limitations & edge cases - -Phase 0 does not fully replace web3.js. Notable gaps: - -- Only the Connection methods listed above are implemented. Any other Web3 call - (e.g. `getTransaction`, subscriptions, `requestAirdrop`) still needs the - legacy connection for now -- `getProgramAccounts` currently returns just the value array even when - `withContext: true` is supplied -- Account data is decoded from `base64` only. Other encodings such as - `jsonParsed` or `base64+zstd` are passed through to Kit but not post‑processed -- Numeric fields are coerced to JavaScript `number`s to match Web3 behaviour, - which means values above `Number.MAX_SAFE_INTEGER` will lose precision (which is how it - currently works) -- The compatibility layer does not yet try to normalise websocket connection - options or retry policies that web3.js exposes - -Future phases will expand coverage and introduce intentional -breaking changes once users have an easy migration path. +## Notes + +- Re-exports all `@solana/web3.js` types (`PublicKey`, `Keypair`, `Transaction`, etc.) +- Numeric fields coerced to `number` to match web3.js behavior +- `LAMPORTS_PER_SOL` and `sendAndConfirmTransaction` available as utilities + +> **Future direction:** This package provides a migration path from `@solana/web3.js` to `@solana/kit`. Over time, more APIs will be deprecated in favor of Kit-native implementations. Use `connection.client` to gradually adopt `@solana/client` features. diff --git a/packages/web3-compat/src/connection.ts b/packages/web3-compat/src/connection.ts index 8444c86..e5e2f16 100644 --- a/packages/web3-compat/src/connection.ts +++ b/packages/web3-compat/src/connection.ts @@ -1,187 +1,108 @@ import type { Address } from '@solana/addresses'; -import { createSolanaRpcClient, type SolanaRpcClient } from '@solana/client'; +import { createClient, type SolanaClient } from '@solana/client'; import type { Commitment as KitCommitment, Signature } from '@solana/kit'; -import type { Base64EncodedWireTransaction } from '@solana/transactions'; import { type AccountInfo, - type ConnectionConfig, + type BlockProduction, + type BlockResponse, + type BlockSignatures, + type ConfirmedSignatureInfo, + type ContactInfo, + type Context, type DataSlice, + type EpochInfo, + type Finality, + type GetParsedProgramAccountsConfig, + type GetVersionedTransactionConfig, + type InflationGovernor, + type InflationRate, + type InflationReward, + type KeyedAccountInfo, + type LeaderSchedule, type Commitment as LegacyCommitment, + type Logs, + type ParsedAccountData, + type ParsedBlockResponse, + type ParsedTransactionWithMeta, + type PerfSample, PublicKey, + type RecentPrioritizationFees, + type RpcResponseAndContext, type SendOptions, type SignatureStatus, - type SignatureStatusConfig, + type SignatureSubscriptionOptions, + type SignaturesForAddressOptions, + type Signer, type SimulatedTransactionResponse, type SimulateTransactionConfig, + type SlotInfo, + type SlotUpdate, + type Supply, + type TokenAmount, Transaction, + type TransactionError, + type TransactionResponse, type TransactionSignature, - VersionedTransaction, + type Version, + type VersionedBlockResponse, + type VersionedTransaction, + type VersionedTransactionResponse, + type VoteAccountStatus, } from '@solana/web3.js'; - -import { toAddress as toKitAddress } from './bridges'; - -type NormalizedCommitment = 'processed' | 'confirmed' | 'finalized'; - -type RpcContext = Readonly<{ - apiVersion?: string; - slot: number; -}>; - -type AccountInfoConfig = Readonly<{ - commitment?: LegacyCommitment; - dataSlice?: DataSlice; - encoding?: 'base64'; - minContextSlot?: number; -}>; - -type ProgramAccountsConfig = Readonly<{ - commitment?: LegacyCommitment; - dataSlice?: DataSlice; - encoding?: 'base64' | 'base64+zstd'; - filters?: ReadonlyArray; - minContextSlot?: number; - withContext?: boolean; -}>; - -type ConnectionCommitmentInput = - | LegacyCommitment - | (ConnectionConfig & { - commitment?: LegacyCommitment; - }) - | undefined; - -type RpcResponseWithContext = Readonly<{ - context: RpcContext; - value: T; -}>; - -type RawTransactionInput = number[] | Uint8Array | Buffer | Transaction | VersionedTransaction; - -type RpcAccount = Readonly<{ - data: readonly [string, string] | string; - executable: boolean; - lamports: number | bigint; - owner: string; - rentEpoch: number | bigint; -}>; - -type ProgramAccountWire = Readonly<{ - account: RpcAccount; - pubkey: string; -}>; - -type ProgramAccountsWithContext = Readonly<{ - context: Readonly<{ - apiVersion?: string; - slot: number | bigint; - }>; - value: readonly ProgramAccountWire[]; -}>; - -type SignatureStatusConfigWithCommitment = SignatureStatusConfig & { - commitment?: LegacyCommitment; -}; - -const DEFAULT_COMMITMENT: NormalizedCommitment = 'confirmed'; - -const DEFAULT_SIMULATION_CONFIG = Object.freeze({ - encoding: 'base64' as const, - replaceRecentBlockhash: true as const, - sigVerify: false as const, -}); - -function normalizeCommitment(commitment?: LegacyCommitment | null): NormalizedCommitment | undefined { - if (commitment === undefined || commitment === null) { - return undefined; - } - if (commitment === 'recent') { - return 'processed'; - } - if (commitment === 'singleGossip') { - return 'processed'; - } - if (commitment === 'single') { - return 'confirmed'; - } - if (commitment === 'max') { - return 'finalized'; - } - return commitment as NormalizedCommitment; -} - -function toBigInt(value: number | bigint | undefined): bigint | undefined { - if (value === undefined) return undefined; - return typeof value === 'bigint' ? value : BigInt(Math.trunc(value)); -} - -function toAccountInfo(info: RpcAccount, dataSlice?: DataSlice): AccountInfo { - const { data, executable, lamports, owner, rentEpoch } = info; - const [content, encoding] = Array.isArray(data) ? data : [data, 'base64']; - let buffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content); - if (dataSlice) { - const start = dataSlice.offset ?? 0; - const end = start + (dataSlice.length ?? buffer.length); - buffer = buffer.subarray(start, end); - } - return { - data: buffer, - executable, - lamports: typeof lamports === 'number' ? lamports : Number(lamports), - owner: new PublicKey(owner), - rentEpoch: typeof rentEpoch === 'number' ? rentEpoch : Number(rentEpoch), - }; -} - -function fromKitAccount(value: unknown): RpcAccount { - const account = (value ?? {}) as Record; - const data = account.data as string | readonly [string, string] | undefined; - const lamports = account.lamports as number | bigint | undefined; - const ownerValue = account.owner as unknown; - const rentEpoch = account.rentEpoch as number | bigint | undefined; - const owner = - typeof ownerValue === 'string' - ? ownerValue - : ownerValue instanceof PublicKey - ? ownerValue.toBase58() - : typeof ownerValue === 'object' && ownerValue !== null && 'toString' in ownerValue - ? String(ownerValue) - : '11111111111111111111111111111111'; - return { - data: data ?? ['', 'base64'], - executable: Boolean(account.executable), - lamports: lamports ?? 0, - owner, - rentEpoch: rentEpoch ?? 0, - }; -} - -function toKitAddressFromInput(input: PublicKey | string): Address { - return toKitAddress(input instanceof PublicKey ? input : input); -} - -function toBase64WireTransaction(raw: RawTransactionInput): Base64EncodedWireTransaction { - if (raw instanceof Transaction || raw instanceof VersionedTransaction) { - const bytes = raw.serialize({ - requireAllSignatures: false, - verifySignatures: false, - }); - return Buffer.from(bytes).toString('base64') as Base64EncodedWireTransaction; - } - if (raw instanceof Uint8Array) { - return Buffer.from(raw).toString('base64') as Base64EncodedWireTransaction; - } - if (raw instanceof Buffer) { - return raw.toString('base64') as Base64EncodedWireTransaction; - } - const uint8 = Uint8Array.from(raw); - return Buffer.from(uint8).toString('base64') as Base64EncodedWireTransaction; -} +import { + DEFAULT_COMMITMENT, + DEFAULT_SIMULATION_CONFIG, + fromKitAccount, + normalizeCommitment, + toAccountInfo, + toBase64WireTransaction, + toBigInt, + toKitAddressFromInput, + toParsedAccountInfo, +} from './connection/adapters'; +import type { + AccountChangeCallback, + AccountInfoConfig, + ConnectionCommitmentInput, + GetBlockConfig, + GetMultipleAccountsConfig, + GetMultipleParsedAccountsConfig, + GetParsedAccountInfoConfig, + GetParsedBlockConfig, + GetParsedTransactionConfig, + GetTokenAccountsByOwnerConfig, + GetTransactionConfig, + KitBlockResponse, + KitSignatureInfo, + KitTransactionResponse, + LogsCallback, + LogsFilter, + NormalizedCommitment, + ProgramAccountChangeCallback, + ProgramAccountsConfig, + ProgramAccountsWithContext, + ProgramAccountWire, + RawTransactionInput, + RootChangeCallback, + RpcAccount, + RpcContext, + RpcResponseWithContext, + SignatureResultCallback, + SignatureStatusConfigWithCommitment, + SignatureSubscriptionCallback, + SlotChangeCallback, + SlotUpdateCallback, + SubscriptionEntry, + TokenAccountsFilter, +} from './types'; export class Connection { readonly commitment?: NormalizedCommitment; readonly rpcEndpoint: string; - #client: SolanaRpcClient; + #client: SolanaClient; + #subscriptions: Map = new Map(); + #nextSubscriptionId = 0; constructor(endpoint: string, commitmentOrConfig?: ConnectionCommitmentInput) { const commitment = @@ -194,13 +115,42 @@ export class Connection { ? commitmentOrConfig.wsEndpoint : undefined; - this.commitment = commitment; - this.rpcEndpoint = endpoint; - this.#client = createSolanaRpcClient({ - endpoint, - websocketEndpoint, - commitment: (commitment ?? DEFAULT_COMMITMENT) as KitCommitment, - }); + // Check if an existing SolanaClient was provided + if ( + typeof commitmentOrConfig === 'object' && + commitmentOrConfig !== null && + 'client' in commitmentOrConfig && + commitmentOrConfig.client + ) { + this.#client = commitmentOrConfig.client as SolanaClient; + this.commitment = commitment; + this.rpcEndpoint = endpoint || ''; + } else { + this.commitment = commitment; + this.rpcEndpoint = endpoint; + this.#client = createClient({ + endpoint, + websocket: websocketEndpoint, + commitment: (commitment ?? DEFAULT_COMMITMENT) as KitCommitment, + }); + } + } + + /** + * Get the underlying SolanaClient for migration to @solana/client. + * + * @example + * ```typescript + * const connection = new Connection('https://api.mainnet.solana.com'); + * const client = connection.client; + * + * // Use SolanaClient features + * await client.actions.connectWallet('phantom'); + * const balance = await client.actions.fetchBalance(address); + * ``` + */ + get client(): SolanaClient { + return this.#client; } async getLatestBlockhash( @@ -232,7 +182,7 @@ export class Connection { ) { requestOptions.maxSupportedTransactionVersion = commitmentOrConfig.maxSupportedTransactionVersion; } - const response = await this.#client.rpc.getLatestBlockhash(requestOptions as never).send(); + const response = await this.#client.runtime.rpc.getLatestBlockhash(requestOptions as never).send(); return { blockhash: response.value.blockhash, @@ -243,7 +193,7 @@ export class Connection { async getBalance(publicKey: PublicKey | string, commitment?: LegacyCommitment): Promise { const address = toKitAddressFromInput(publicKey); const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; - const result = await this.#client.rpc + const result = await this.#client.runtime.rpc .getBalance(address, { commitment: chosenCommitment as KitCommitment }) .send(); return typeof result.value === 'number' ? result.value : Number(result.value); @@ -285,7 +235,7 @@ export class Connection { }; } - const response = await this.#client.rpc.getAccountInfo(address, requestOptions as never).send(); + const response = await this.#client.runtime.rpc.getAccountInfo(address, requestOptions as never).send(); if (!response.value) { return null; @@ -348,7 +298,7 @@ export class Connection { requestOptions.minContextSlot = minContextSlot; } - const result = await this.#client.rpc.getProgramAccounts(id, requestOptions as never).send(); + const result = await this.#client.runtime.rpc.getProgramAccounts(id, requestOptions as never).send(); const mapProgramAccount = (entry: ProgramAccountWire) => { const pubkey = new PublicKey(entry.pubkey); @@ -376,13 +326,11 @@ export class Connection { signatures: readonly TransactionSignature[], config?: SignatureStatusConfigWithCommitment, ): Promise> { - const targetCommitment = normalizeCommitment(config?.commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; const kitSignatures = signatures.map((signature) => signature as unknown as Signature); - const response = await this.#client.rpc + const response = await this.#client.runtime.rpc .getSignatureStatuses(kitSignatures, { - commitment: targetCommitment as KitCommitment, - searchTransactionHistory: config?.searchTransactionHistory, - } as never) + searchTransactionHistory: config?.searchTransactionHistory ?? false, + }) .send(); const context = response.context as { slot: number | bigint; apiVersion?: string }; @@ -421,7 +369,10 @@ export class Connection { value: normalizedValues as (SignatureStatus | null)[], }; } - async sendRawTransaction(rawTransaction: RawTransactionInput, options?: SendOptions): Promise { + async sendRawTransaction( + rawTransaction: RawTransactionInput | Transaction | VersionedTransaction, + options?: SendOptions, + ): Promise { const wire = toBase64WireTransaction(rawTransaction); const preflightCommitment = @@ -433,7 +384,7 @@ export class Connection { DEFAULT_COMMITMENT; const maxRetries = options?.maxRetries === undefined ? undefined : toBigInt(options.maxRetries); const minContextSlot = options?.minContextSlot === undefined ? undefined : toBigInt(options.minContextSlot); - const plan = this.#client.rpc.sendTransaction(wire, { + const plan = this.#client.runtime.rpc.sendTransaction(wire, { encoding: 'base64', maxRetries, minContextSlot, @@ -445,18 +396,49 @@ export class Connection { } async confirmTransaction( - signature: TransactionSignature, + strategy: TransactionSignature | { signature: string; blockhash: string; lastValidBlockHeight: number }, commitment?: LegacyCommitment, ): Promise> { - const normalizedCommitment = normalizeCommitment(commitment); - const response = await this.getSignatureStatuses([signature], { - commitment: normalizedCommitment ?? this.commitment ?? DEFAULT_COMMITMENT, + const signature = typeof strategy === 'string' ? strategy : strategy.signature; + const targetCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + // Poll for signature status until confirmed or timeout + const maxAttempts = 30; + const delayMs = 1000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const response = await this.getSignatureStatuses([signature], { + searchTransactionHistory: true, + }); + + const status = response.value[0]; + if (status !== null) { + // Check if confirmation level meets requirement + const confirmationStatus = status.confirmationStatus; + const meetsCommitment = + targetCommitment === 'processed' || + (targetCommitment === 'confirmed' && confirmationStatus !== 'processed') || + (targetCommitment === 'finalized' && confirmationStatus === 'finalized'); + + if (meetsCommitment) { + return { + context: response.context, + value: status, + }; + } + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + // Return last status (may be null if not found) + const finalResponse = await this.getSignatureStatuses([signature], { searchTransactionHistory: true, }); - return { - context: response.context, - value: response.value[0] ?? null, + context: finalResponse.context, + value: finalResponse.value[0] ?? null, }; } @@ -480,7 +462,7 @@ export class Connection { ? { ...mergedConfig, replaceRecentBlockhash: false } : mergedConfig; - const response = await this.#client.rpc.simulateTransaction(wire, normalizedConfig as never).send(); + const response = await this.#client.runtime.rpc.simulateTransaction(wire, normalizedConfig as never).send(); return { context: { @@ -490,4 +472,1692 @@ export class Connection { value: response.value as unknown as SimulatedTransactionResponse, }; } + + // ========== Phase 1: Core High-Frequency Methods ========== + + async getMultipleAccountsInfo( + publicKeys: PublicKey[], + commitmentOrConfig?: LegacyCommitment | GetMultipleAccountsConfig, + ): Promise<(AccountInfo | null)[]> { + const addresses = publicKeys.map((pk) => toKitAddressFromInput(pk)); + + let localCommitment: NormalizedCommitment | undefined; + let minContextSlot: bigint | undefined; + let dataSlice: DataSlice | undefined; + + if (typeof commitmentOrConfig === 'string') { + localCommitment = normalizeCommitment(commitmentOrConfig); + } else if (commitmentOrConfig) { + localCommitment = normalizeCommitment(commitmentOrConfig.commitment); + minContextSlot = toBigInt(commitmentOrConfig.minContextSlot); + dataSlice = commitmentOrConfig.dataSlice; + } + + const requestOptions: Record = { + commitment: (localCommitment ?? this.commitment ?? DEFAULT_COMMITMENT) as KitCommitment, + encoding: 'base64', + }; + if (minContextSlot !== undefined) { + requestOptions.minContextSlot = minContextSlot; + } + if (dataSlice) { + requestOptions.dataSlice = { + length: dataSlice.length, + offset: dataSlice.offset, + }; + } + + const response = await this.#client.runtime.rpc.getMultipleAccounts(addresses, requestOptions as never).send(); + + const values = response.value as readonly (RpcAccount | null)[]; + return values.map((account) => { + if (!account) return null; + return toAccountInfo(fromKitAccount(account), dataSlice); + }); + } + + async getMultipleAccountsInfoAndContext( + publicKeys: PublicKey[], + commitmentOrConfig?: LegacyCommitment | GetMultipleAccountsConfig, + ): Promise | null)[]>> { + const addresses = publicKeys.map((pk) => toKitAddressFromInput(pk)); + + let localCommitment: NormalizedCommitment | undefined; + let minContextSlot: bigint | undefined; + let dataSlice: DataSlice | undefined; + + if (typeof commitmentOrConfig === 'string') { + localCommitment = normalizeCommitment(commitmentOrConfig); + } else if (commitmentOrConfig) { + localCommitment = normalizeCommitment(commitmentOrConfig.commitment); + minContextSlot = toBigInt(commitmentOrConfig.minContextSlot); + dataSlice = commitmentOrConfig.dataSlice; + } + + const requestOptions: Record = { + commitment: (localCommitment ?? this.commitment ?? DEFAULT_COMMITMENT) as KitCommitment, + encoding: 'base64', + }; + if (minContextSlot !== undefined) { + requestOptions.minContextSlot = minContextSlot; + } + if (dataSlice) { + requestOptions.dataSlice = { + length: dataSlice.length, + offset: dataSlice.offset, + }; + } + + const response = await this.#client.runtime.rpc.getMultipleAccounts(addresses, requestOptions as never).send(); + + const context = response.context as { slot: number | bigint; apiVersion?: string }; + const values = response.value as readonly (RpcAccount | null)[]; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: values.map((account) => { + if (!account) return null; + return toAccountInfo(fromKitAccount(account), dataSlice); + }), + }; + } + + async getTokenAccountsByOwner( + ownerAddress: PublicKey, + filter: TokenAccountsFilter, + commitmentOrConfig?: LegacyCommitment | GetTokenAccountsByOwnerConfig, + ): Promise; pubkey: PublicKey }>>> { + const owner = toKitAddressFromInput(ownerAddress); + + let localCommitment: NormalizedCommitment | undefined; + let minContextSlot: bigint | undefined; + let encoding: 'base64' | 'jsonParsed' = 'base64'; + + if (typeof commitmentOrConfig === 'string') { + localCommitment = normalizeCommitment(commitmentOrConfig); + } else if (commitmentOrConfig) { + localCommitment = normalizeCommitment(commitmentOrConfig.commitment); + minContextSlot = toBigInt(commitmentOrConfig.minContextSlot); + if (commitmentOrConfig.encoding) { + encoding = commitmentOrConfig.encoding; + } + } + + const filterParam: Record = {}; + if ('mint' in filter) { + filterParam.mint = filter.mint.toBase58(); + } else if ('programId' in filter) { + filterParam.programId = filter.programId.toBase58(); + } + + const requestOptions: Record = { + commitment: (localCommitment ?? this.commitment ?? DEFAULT_COMMITMENT) as KitCommitment, + encoding, + }; + if (minContextSlot !== undefined) { + requestOptions.minContextSlot = minContextSlot; + } + + const response = await this.#client.runtime.rpc + .getTokenAccountsByOwner(owner, filterParam as never, requestOptions as never) + .send(); + + const context = response.context as { slot: number | bigint; apiVersion?: string }; + const values = response.value as unknown as readonly ProgramAccountWire[]; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: values.map((entry) => ({ + account: toAccountInfo(fromKitAccount(entry.account)), + pubkey: new PublicKey(entry.pubkey), + })), + }; + } + + async getTokenAccountBalance( + tokenAddress: PublicKey, + commitment?: LegacyCommitment, + ): Promise> { + const address = toKitAddressFromInput(tokenAddress); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const response = await this.#client.runtime.rpc + .getTokenAccountBalance(address, { commitment: chosenCommitment as KitCommitment }) + .send(); + + const context = response.context as { slot: number | bigint; apiVersion?: string }; + const value = response.value as { + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString?: string; + }; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: { + amount: value.amount, + decimals: value.decimals, + uiAmount: value.uiAmount, + uiAmountString: value.uiAmountString ?? String(value.uiAmount ?? '0'), + }, + }; + } + + async sendTransaction( + transaction: Transaction | VersionedTransaction, + signersOrOptions?: Signer[] | SendOptions, + options?: SendOptions, + ): Promise { + const tx = transaction; + let sendOptions: SendOptions | undefined; + + if (Array.isArray(signersOrOptions)) { + // Legacy Transaction with signers + if (tx instanceof Transaction) { + if (signersOrOptions.length > 0) { + tx.sign(...signersOrOptions); + } + } + sendOptions = options; + } else { + sendOptions = signersOrOptions; + } + + return this.sendRawTransaction(tx, sendOptions); + } + + async getTransaction( + signature: string, + rawConfig?: GetTransactionConfig | GetVersionedTransactionConfig, + ): Promise { + const config = rawConfig ?? {}; + const commitment = normalizeCommitment(config.commitment as LegacyCommitment) ?? this.commitment; + const finalCommitment = commitment === 'processed' ? 'confirmed' : (commitment ?? 'confirmed'); + + const requestOptions: Record = { + commitment: finalCommitment as KitCommitment, + encoding: 'json', + }; + if (config.maxSupportedTransactionVersion !== undefined) { + requestOptions.maxSupportedTransactionVersion = config.maxSupportedTransactionVersion; + } + + const response = await this.#client.runtime.rpc + .getTransaction(signature as unknown as Signature, requestOptions as never) + .send(); + + if (!response) { + return null; + } + + const tx = response as unknown as KitTransactionResponse; + return this.#mapTransactionResponse(tx); + } + + #mapTransactionResponse(tx: KitTransactionResponse): TransactionResponse | VersionedTransactionResponse { + const meta = tx.meta + ? { + err: tx.meta.err, + fee: typeof tx.meta.fee === 'bigint' ? Number(tx.meta.fee) : tx.meta.fee, + innerInstructions: tx.meta.innerInstructions as TransactionResponse['meta'] extends infer M + ? M extends { innerInstructions: infer I } + ? I + : null + : null, + loadedAddresses: tx.meta.loadedAddresses + ? { + readonly: tx.meta.loadedAddresses.readonly.map((addr) => new PublicKey(addr)), + writable: tx.meta.loadedAddresses.writable.map((addr) => new PublicKey(addr)), + } + : undefined, + logMessages: tx.meta.logMessages as string[] | null, + postBalances: tx.meta.postBalances.map((b) => (typeof b === 'bigint' ? Number(b) : b)), + postTokenBalances: tx.meta.postTokenBalances as TransactionResponse['meta'] extends infer M + ? M extends { postTokenBalances: infer T } + ? T + : null + : null, + preBalances: tx.meta.preBalances.map((b) => (typeof b === 'bigint' ? Number(b) : b)), + preTokenBalances: tx.meta.preTokenBalances as TransactionResponse['meta'] extends infer M + ? M extends { preTokenBalances: infer T } + ? T + : null + : null, + rewards: tx.meta.rewards as TransactionResponse['meta'] extends infer M + ? M extends { rewards: infer R } + ? R + : null + : null, + computeUnitsConsumed: + tx.meta.computeUnitsConsumed !== undefined + ? typeof tx.meta.computeUnitsConsumed === 'bigint' + ? Number(tx.meta.computeUnitsConsumed) + : tx.meta.computeUnitsConsumed + : undefined, + } + : null; + + return { + blockTime: + tx.blockTime !== null ? (typeof tx.blockTime === 'bigint' ? Number(tx.blockTime) : tx.blockTime) : null, + meta, + slot: typeof tx.slot === 'bigint' ? Number(tx.slot) : tx.slot, + transaction: tx.transaction as TransactionResponse['transaction'], + version: tx.version, + } as TransactionResponse | VersionedTransactionResponse; + } + + async getSignaturesForAddress( + address: PublicKey, + options?: SignaturesForAddressOptions, + commitment?: Finality, + ): Promise { + const addr = toKitAddressFromInput(address); + const chosenCommitment = normalizeCommitment(commitment as LegacyCommitment) ?? this.commitment; + const finalCommitment = chosenCommitment === 'processed' ? 'confirmed' : (chosenCommitment ?? 'confirmed'); + + const requestOptions: Record = { + commitment: finalCommitment as KitCommitment, + }; + if (options?.limit !== undefined) { + requestOptions.limit = options.limit; + } + if (options?.before !== undefined) { + requestOptions.before = options.before; + } + if (options?.until !== undefined) { + requestOptions.until = options.until; + } + if (options?.minContextSlot !== undefined) { + requestOptions.minContextSlot = toBigInt(options.minContextSlot); + } + + const response = await this.#client.runtime.rpc.getSignaturesForAddress(addr, requestOptions as never).send(); + + const signatures = response as readonly KitSignatureInfo[]; + return signatures.map((sig) => ({ + blockTime: + sig.blockTime !== null + ? typeof sig.blockTime === 'bigint' + ? Number(sig.blockTime) + : sig.blockTime + : null, + confirmationStatus: sig.confirmationStatus as ConfirmedSignatureInfo['confirmationStatus'], + err: sig.err as TransactionError | null, + memo: sig.memo, + signature: sig.signature, + slot: typeof sig.slot === 'bigint' ? Number(sig.slot) : sig.slot, + })); + } + + async getSlot( + commitmentOrConfig?: LegacyCommitment | { commitment?: LegacyCommitment; minContextSlot?: number }, + ): Promise { + let localCommitment: NormalizedCommitment | undefined; + let minContextSlot: bigint | undefined; + + if (typeof commitmentOrConfig === 'string') { + localCommitment = normalizeCommitment(commitmentOrConfig); + } else if (commitmentOrConfig) { + localCommitment = normalizeCommitment(commitmentOrConfig.commitment); + minContextSlot = toBigInt(commitmentOrConfig.minContextSlot); + } + + const requestOptions: Record = { + commitment: (localCommitment ?? this.commitment ?? DEFAULT_COMMITMENT) as KitCommitment, + }; + if (minContextSlot !== undefined) { + requestOptions.minContextSlot = minContextSlot; + } + + const response = await this.#client.runtime.rpc.getSlot(requestOptions as never).send(); + return typeof response === 'bigint' ? Number(response) : (response as number); + } + + async requestAirdrop(to: PublicKey, lamports: number): Promise { + const address = toKitAddressFromInput(to); + // Cast through unknown since requestAirdrop is only available on devnet/testnet + const rpc = this.#client.runtime.rpc as unknown as { + requestAirdrop: ( + address: Address, + lamports: bigint, + config?: { commitment?: KitCommitment }, + ) => { send: () => Promise }; + }; + const response = await rpc + .requestAirdrop(address, BigInt(lamports), { + commitment: (this.commitment ?? DEFAULT_COMMITMENT) as KitCommitment, + }) + .send(); + return response; + } + + async getMinimumBalanceForRentExemption(dataLength: number, commitment?: LegacyCommitment): Promise { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const response = await this.#client.runtime.rpc + .getMinimumBalanceForRentExemption(BigInt(dataLength), { + commitment: chosenCommitment as KitCommitment, + }) + .send(); + + return typeof response === 'bigint' ? Number(response) : (response as number); + } + + // ========== Phase 2: Block and Transaction History ========== + + async getBlock(slot: number, rawConfig?: GetBlockConfig): Promise { + const config = rawConfig ?? {}; + const commitment = normalizeCommitment(config.commitment as LegacyCommitment) ?? this.commitment; + const finalCommitment = commitment === 'processed' ? 'confirmed' : (commitment ?? 'confirmed'); + + const requestOptions: Record = { + commitment: finalCommitment as KitCommitment, + encoding: 'json', + transactionDetails: config.transactionDetails ?? 'full', + }; + if (config.maxSupportedTransactionVersion !== undefined) { + requestOptions.maxSupportedTransactionVersion = config.maxSupportedTransactionVersion; + } + if (config.rewards !== undefined) { + requestOptions.rewards = config.rewards; + } + + const response = await this.#client.runtime.rpc.getBlock(BigInt(slot), requestOptions as never).send(); + + if (!response) { + return null; + } + + const block = response as unknown as KitBlockResponse; + return { + blockHeight: + block.blockHeight !== null + ? typeof block.blockHeight === 'bigint' + ? Number(block.blockHeight) + : block.blockHeight + : null, + blockTime: + block.blockTime !== null + ? typeof block.blockTime === 'bigint' + ? Number(block.blockTime) + : block.blockTime + : null, + blockhash: block.blockhash, + parentSlot: typeof block.parentSlot === 'bigint' ? Number(block.parentSlot) : block.parentSlot, + previousBlockhash: block.previousBlockhash, + rewards: block.rewards as BlockResponse['rewards'], + transactions: block.transactions as BlockResponse['transactions'], + } as BlockResponse | VersionedBlockResponse; + } + + async getBlockTime(slot: number): Promise { + const response = await this.#client.runtime.rpc.getBlockTime(BigInt(slot)).send(); + if (response === null) { + return null; + } + return typeof response === 'bigint' ? Number(response) : (response as number); + } + + async getBlockHeight(commitment?: LegacyCommitment): Promise { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getBlockHeight({ commitment: chosenCommitment as KitCommitment }) + .send(); + return typeof response === 'bigint' ? Number(response) : (response as number); + } + + async getBlocks(startSlot: number, endSlot?: number, commitment?: Finality): Promise { + const chosenCommitment = normalizeCommitment(commitment as LegacyCommitment) ?? this.commitment; + const finalCommitment = chosenCommitment === 'processed' ? 'confirmed' : (chosenCommitment ?? 'confirmed'); + + const response = await this.#client.runtime.rpc + .getBlocks(BigInt(startSlot), endSlot !== undefined ? BigInt(endSlot) : undefined, { + commitment: finalCommitment as KitCommitment, + } as never) + .send(); + + return (response as readonly (number | bigint)[]).map((slot) => + typeof slot === 'bigint' ? Number(slot) : slot, + ); + } + + async getBlockSignatures(slot: number, commitment?: Finality): Promise { + const chosenCommitment = normalizeCommitment(commitment as LegacyCommitment) ?? this.commitment; + const finalCommitment = chosenCommitment === 'processed' ? 'confirmed' : (chosenCommitment ?? 'confirmed'); + + const response = await this.#client.runtime.rpc + .getBlock(BigInt(slot), { + commitment: finalCommitment as KitCommitment, + transactionDetails: 'signatures', + rewards: false, + } as never) + .send(); + + if (!response) { + throw new Error(`Block not found: ${slot}`); + } + + const block = response as unknown as KitBlockResponse; + return { + blockTime: + block.blockTime !== null + ? typeof block.blockTime === 'bigint' + ? Number(block.blockTime) + : block.blockTime + : null, + blockhash: block.blockhash, + parentSlot: typeof block.parentSlot === 'bigint' ? Number(block.parentSlot) : block.parentSlot, + previousBlockhash: block.previousBlockhash, + signatures: (block.signatures ?? []) as string[], + }; + } + + async isBlockhashValid(blockhash: string, commitment?: LegacyCommitment): Promise> { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const response = await this.#client.runtime.rpc + .isBlockhashValid(blockhash as never, { commitment: chosenCommitment as KitCommitment }) + .send(); + + const context = response.context as { slot: number | bigint; apiVersion?: string }; + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: response.value as boolean, + }; + } + + async getFeeForMessage( + message: string, + commitment?: LegacyCommitment, + ): Promise> { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const response = await this.#client.runtime.rpc + .getFeeForMessage(message as never, { commitment: chosenCommitment as KitCommitment }) + .send(); + + const context = response.context as { slot: number | bigint; apiVersion?: string }; + const value = response.value as number | bigint | null; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: value !== null ? (typeof value === 'bigint' ? Number(value) : value) : null, + }; + } + + async getRecentPrioritizationFees(lockedWritableAccounts?: PublicKey[]): Promise { + const addresses = lockedWritableAccounts?.map((pk) => toKitAddressFromInput(pk)); + + const response = await this.#client.runtime.rpc.getRecentPrioritizationFees(addresses as never).send(); + + return (response as readonly { prioritizationFee: number | bigint; slot: number | bigint }[]).map((fee) => ({ + prioritizationFee: + typeof fee.prioritizationFee === 'bigint' ? Number(fee.prioritizationFee) : fee.prioritizationFee, + slot: typeof fee.slot === 'bigint' ? Number(fee.slot) : fee.slot, + })); + } + + async getAccountInfoAndContext( + publicKey: PublicKey | string, + commitmentOrConfig?: AccountInfoConfig | LegacyCommitment, + ): Promise | null>> { + const address = toKitAddressFromInput(publicKey); + let localCommitment: NormalizedCommitment | undefined; + let minContextSlot: bigint | undefined; + let dataSlice: DataSlice | undefined; + let encoding: 'base64' | undefined; + if (typeof commitmentOrConfig === 'string') { + localCommitment = normalizeCommitment(commitmentOrConfig); + } else if (commitmentOrConfig) { + localCommitment = normalizeCommitment(commitmentOrConfig.commitment); + if (commitmentOrConfig.minContextSlot !== undefined) { + minContextSlot = toBigInt(commitmentOrConfig.minContextSlot); + } + dataSlice = commitmentOrConfig.dataSlice; + encoding = commitmentOrConfig.encoding; + } + + const requestOptions: Record = { + commitment: (localCommitment ?? this.commitment ?? DEFAULT_COMMITMENT) as KitCommitment, + }; + if (minContextSlot !== undefined) { + requestOptions.minContextSlot = minContextSlot; + } + if (encoding) { + requestOptions.encoding = encoding; + } + if (dataSlice) { + requestOptions.dataSlice = { + length: dataSlice.length, + offset: dataSlice.offset, + }; + } + + const response = await this.#client.runtime.rpc.getAccountInfo(address, requestOptions as never).send(); + const context = response.context as { slot: number | bigint; apiVersion?: string }; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: !response.value + ? null + : (toAccountInfo(fromKitAccount(response.value), dataSlice) as AccountInfo), + }; + } + + async getBalanceAndContext( + publicKey: PublicKey | string, + commitment?: LegacyCommitment, + ): Promise> { + const address = toKitAddressFromInput(publicKey); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const result = await this.#client.runtime.rpc + .getBalance(address, { commitment: chosenCommitment as KitCommitment }) + .send(); + + const context = result.context as { slot: number | bigint; apiVersion?: string }; + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: typeof result.value === 'number' ? result.value : Number(result.value), + }; + } + + async getLatestBlockhashAndContext( + commitmentOrConfig?: + | LegacyCommitment + | { + commitment?: LegacyCommitment; + minContextSlot?: number; + }, + ): Promise> { + const baseCommitment = + typeof commitmentOrConfig === 'string' ? commitmentOrConfig : commitmentOrConfig?.commitment; + const commitment = normalizeCommitment(baseCommitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const minContextSlot = + typeof commitmentOrConfig === 'object' ? toBigInt(commitmentOrConfig?.minContextSlot) : undefined; + + const requestOptions: Record = { + commitment: commitment as KitCommitment, + }; + if (minContextSlot !== undefined) { + requestOptions.minContextSlot = minContextSlot; + } + + const response = await this.#client.runtime.rpc.getLatestBlockhash(requestOptions as never).send(); + const context = response.context as { slot: number | bigint; apiVersion?: string }; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: { + blockhash: response.value.blockhash, + lastValidBlockHeight: Number(response.value.lastValidBlockHeight), + }, + }; + } + + // ========== Phase 4-7: Slot, Epoch, Cluster, Stake, Inflation, Misc ========== + + async getSlotLeader(commitment?: LegacyCommitment): Promise { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getSlotLeader({ commitment: chosenCommitment as KitCommitment }) + .send(); + return response as string; + } + + async getSlotLeaders(startSlot: number, limit: number): Promise { + const response = await this.#client.runtime.rpc.getSlotLeaders(BigInt(startSlot), limit).send(); + return (response as readonly string[]).map((addr) => new PublicKey(addr)); + } + + async getEpochInfo(commitment?: LegacyCommitment): Promise { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getEpochInfo({ commitment: chosenCommitment as KitCommitment }) + .send(); + + const info = response as { + absoluteSlot: number | bigint; + blockHeight: number | bigint; + epoch: number | bigint; + slotIndex: number | bigint; + slotsInEpoch: number | bigint; + transactionCount?: number | bigint; + }; + + return { + absoluteSlot: typeof info.absoluteSlot === 'bigint' ? Number(info.absoluteSlot) : info.absoluteSlot, + blockHeight: typeof info.blockHeight === 'bigint' ? Number(info.blockHeight) : info.blockHeight, + epoch: typeof info.epoch === 'bigint' ? Number(info.epoch) : info.epoch, + slotIndex: typeof info.slotIndex === 'bigint' ? Number(info.slotIndex) : info.slotIndex, + slotsInEpoch: typeof info.slotsInEpoch === 'bigint' ? Number(info.slotsInEpoch) : info.slotsInEpoch, + transactionCount: + info.transactionCount !== undefined + ? typeof info.transactionCount === 'bigint' + ? Number(info.transactionCount) + : info.transactionCount + : undefined, + }; + } + + async getEpochSchedule(): Promise<{ + firstNormalEpoch: number; + firstNormalSlot: number; + leaderScheduleSlotOffset: number; + slotsPerEpoch: number; + warmup: boolean; + }> { + const response = await this.#client.runtime.rpc.getEpochSchedule().send(); + const schedule = response as { + firstNormalEpoch: number | bigint; + firstNormalSlot: number | bigint; + leaderScheduleSlotOffset: number | bigint; + slotsPerEpoch: number | bigint; + warmup: boolean; + }; + + return { + firstNormalEpoch: + typeof schedule.firstNormalEpoch === 'bigint' + ? Number(schedule.firstNormalEpoch) + : schedule.firstNormalEpoch, + firstNormalSlot: + typeof schedule.firstNormalSlot === 'bigint' + ? Number(schedule.firstNormalSlot) + : schedule.firstNormalSlot, + leaderScheduleSlotOffset: + typeof schedule.leaderScheduleSlotOffset === 'bigint' + ? Number(schedule.leaderScheduleSlotOffset) + : schedule.leaderScheduleSlotOffset, + slotsPerEpoch: + typeof schedule.slotsPerEpoch === 'bigint' ? Number(schedule.slotsPerEpoch) : schedule.slotsPerEpoch, + warmup: schedule.warmup, + }; + } + + async getLeaderSchedule(slot?: number, commitment?: LegacyCommitment): Promise { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getLeaderSchedule(slot !== undefined ? BigInt(slot) : (undefined as never), { + commitment: chosenCommitment as KitCommitment, + } as never) + .send(); + + if (!response) { + return null; + } + + const schedule = response as Record; + const result: LeaderSchedule = {}; + for (const [leader, slots] of Object.entries(schedule)) { + result[leader] = slots.map((s) => (typeof s === 'bigint' ? Number(s) : s)); + } + return result; + } + + async getClusterNodes(): Promise { + const response = await this.#client.runtime.rpc.getClusterNodes().send(); + return response as unknown as ContactInfo[]; + } + + async getVoteAccounts(commitment?: LegacyCommitment): Promise { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getVoteAccounts({ commitment: chosenCommitment as KitCommitment }) + .send(); + + const result = response as { + current: readonly unknown[]; + delinquent: readonly unknown[]; + }; + + const mapVoteAccount = (account: Record) => ({ + ...account, + activatedStake: + typeof account.activatedStake === 'bigint' ? Number(account.activatedStake) : account.activatedStake, + lastVote: typeof account.lastVote === 'bigint' ? Number(account.lastVote) : account.lastVote, + rootSlot: typeof account.rootSlot === 'bigint' ? Number(account.rootSlot) : account.rootSlot, + }); + + return { + current: result.current.map((a) => mapVoteAccount(a as Record)), + delinquent: result.delinquent.map((a) => mapVoteAccount(a as Record)), + } as unknown as VoteAccountStatus; + } + + async getVersion(): Promise { + const response = await this.#client.runtime.rpc.getVersion().send(); + return response as unknown as Version; + } + + async getHealth(): Promise { + const response = await this.#client.runtime.rpc.getHealth().send(); + return response as string; + } + + async getSupply(commitment?: LegacyCommitment): Promise> { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getSupply({ commitment: chosenCommitment as KitCommitment }) + .send(); + + const context = response.context as { slot: number | bigint }; + const value = response.value as { + circulating: number | bigint; + nonCirculating: number | bigint; + nonCirculatingAccounts: readonly string[]; + total: number | bigint; + }; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: { + circulating: typeof value.circulating === 'bigint' ? Number(value.circulating) : value.circulating, + nonCirculating: + typeof value.nonCirculating === 'bigint' ? Number(value.nonCirculating) : value.nonCirculating, + nonCirculatingAccounts: value.nonCirculatingAccounts.map((addr) => new PublicKey(addr)), + total: typeof value.total === 'bigint' ? Number(value.total) : value.total, + }, + }; + } + + async getTokenSupply( + tokenMintAddress: PublicKey, + commitment?: LegacyCommitment, + ): Promise> { + const address = toKitAddressFromInput(tokenMintAddress); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const response = await this.#client.runtime.rpc + .getTokenSupply(address, { commitment: chosenCommitment as KitCommitment }) + .send(); + + const context = response.context as { slot: number | bigint }; + const value = response.value as { + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString?: string; + }; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: { + amount: value.amount, + decimals: value.decimals, + uiAmount: value.uiAmount, + uiAmountString: value.uiAmountString ?? String(value.uiAmount ?? '0'), + }, + }; + } + + async getLargestAccounts(config?: { + commitment?: LegacyCommitment; + filter?: 'circulating' | 'nonCirculating'; + }): Promise>> { + const chosenCommitment = normalizeCommitment(config?.commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + }; + if (config?.filter) { + requestOptions.filter = config.filter; + } + + const response = await this.#client.runtime.rpc.getLargestAccounts(requestOptions as never).send(); + const context = response.context as { slot: number | bigint }; + const values = response.value as readonly { address: string; lamports: number | bigint }[]; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: values.map((v) => ({ + address: new PublicKey(v.address), + lamports: typeof v.lamports === 'bigint' ? Number(v.lamports) : v.lamports, + })), + }; + } + + async getTokenLargestAccounts( + mintAddress: PublicKey, + commitment?: LegacyCommitment, + ): Promise< + RpcResponseAndContext< + Array<{ + address: PublicKey; + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString: string; + }> + > + > { + const address = toKitAddressFromInput(mintAddress); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const response = await this.#client.runtime.rpc + .getTokenLargestAccounts(address, { commitment: chosenCommitment as KitCommitment }) + .send(); + + const context = response.context as { slot: number | bigint }; + const values = response.value as readonly { + address: string; + amount: string; + decimals: number; + uiAmount: number | null; + uiAmountString?: string; + }[]; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: values.map((v) => ({ + address: new PublicKey(v.address), + amount: v.amount, + decimals: v.decimals, + uiAmount: v.uiAmount, + uiAmountString: v.uiAmountString ?? String(v.uiAmount ?? '0'), + })), + }; + } + + async getInflationGovernor(commitment?: LegacyCommitment): Promise { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getInflationGovernor({ commitment: chosenCommitment as KitCommitment }) + .send(); + return response as unknown as InflationGovernor; + } + + async getInflationRate(): Promise { + const response = await this.#client.runtime.rpc.getInflationRate().send(); + const rate = response as { + epoch: number | bigint; + foundation: number; + total: number; + validator: number; + }; + return { + epoch: typeof rate.epoch === 'bigint' ? Number(rate.epoch) : rate.epoch, + foundation: rate.foundation, + total: rate.total, + validator: rate.validator, + }; + } + + async getInflationReward( + addresses: PublicKey[], + epoch?: number, + commitment?: LegacyCommitment, + ): Promise<(InflationReward | null)[]> { + const addrs = addresses.map((pk) => toKitAddressFromInput(pk)); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + }; + if (epoch !== undefined) { + requestOptions.epoch = BigInt(epoch); + } + + const response = await this.#client.runtime.rpc.getInflationReward(addrs, requestOptions as never).send(); + + return (response as readonly (Record | null)[]).map((reward) => { + if (!reward) return null; + return { + amount: typeof reward.amount === 'bigint' ? Number(reward.amount) : (reward.amount as number), + effectiveSlot: + typeof reward.effectiveSlot === 'bigint' + ? Number(reward.effectiveSlot) + : (reward.effectiveSlot as number), + epoch: typeof reward.epoch === 'bigint' ? Number(reward.epoch) : (reward.epoch as number), + postBalance: + typeof reward.postBalance === 'bigint' + ? Number(reward.postBalance) + : (reward.postBalance as number), + commission: reward.commission as number | undefined, + } as InflationReward; + }); + } + + async getStakeMinimumDelegation(commitment?: LegacyCommitment): Promise> { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getStakeMinimumDelegation({ commitment: chosenCommitment as KitCommitment }) + .send(); + + const context = response.context as { slot: number | bigint }; + const value = response.value as number | bigint; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: typeof value === 'bigint' ? Number(value) : value, + }; + } + + async getFirstAvailableBlock(): Promise { + const response = await this.#client.runtime.rpc.getFirstAvailableBlock().send(); + return typeof response === 'bigint' ? Number(response) : (response as number); + } + + async getTransactionCount(commitment?: LegacyCommitment): Promise { + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + const response = await this.#client.runtime.rpc + .getTransactionCount({ commitment: chosenCommitment as KitCommitment }) + .send(); + return typeof response === 'bigint' ? Number(response) : (response as number); + } + + async getGenesisHash(): Promise { + const response = await this.#client.runtime.rpc.getGenesisHash().send(); + return response as string; + } + + async getRecentPerformanceSamples(limit?: number): Promise { + const response = await this.#client.runtime.rpc.getRecentPerformanceSamples(limit as never).send(); + + return (response as readonly Record[]).map((sample) => ({ + numSlots: typeof sample.numSlots === 'bigint' ? Number(sample.numSlots) : (sample.numSlots as number), + numTransactions: + typeof sample.numTransactions === 'bigint' + ? Number(sample.numTransactions) + : (sample.numTransactions as number), + numNonVoteTransactions: + sample.numNonVoteTransactions !== undefined + ? typeof sample.numNonVoteTransactions === 'bigint' + ? Number(sample.numNonVoteTransactions) + : (sample.numNonVoteTransactions as number) + : undefined, + samplePeriodSecs: sample.samplePeriodSecs as number, + slot: typeof sample.slot === 'bigint' ? Number(sample.slot) : (sample.slot as number), + })) as PerfSample[]; + } + + async getMinimumLedgerSlot(): Promise { + const response = await this.#client.runtime.rpc.minimumLedgerSlot().send(); + return typeof response === 'bigint' ? Number(response) : (response as number); + } + + async getBlockProduction(config?: { + commitment?: LegacyCommitment; + range?: { firstSlot: number; lastSlot?: number }; + identity?: string; + }): Promise> { + const chosenCommitment = normalizeCommitment(config?.commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + }; + if (config?.range) { + requestOptions.range = { + firstSlot: BigInt(config.range.firstSlot), + lastSlot: config.range.lastSlot !== undefined ? BigInt(config.range.lastSlot) : undefined, + }; + } + if (config?.identity) { + requestOptions.identity = config.identity; + } + + const response = await this.#client.runtime.rpc.getBlockProduction(requestOptions as never).send(); + const context = response.context as { slot: number | bigint }; + const value = response.value as { + byIdentity: Record; + range: { firstSlot: number | bigint; lastSlot: number | bigint }; + }; + + const byIdentity: Record = {}; + for (const [identity, [leaderSlots, blocksProduced]] of Object.entries(value.byIdentity)) { + byIdentity[identity] = [ + typeof leaderSlots === 'bigint' ? Number(leaderSlots) : leaderSlots, + typeof blocksProduced === 'bigint' ? Number(blocksProduced) : blocksProduced, + ]; + } + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: { + byIdentity, + range: { + firstSlot: + typeof value.range.firstSlot === 'bigint' + ? Number(value.range.firstSlot) + : value.range.firstSlot, + lastSlot: + typeof value.range.lastSlot === 'bigint' ? Number(value.range.lastSlot) : value.range.lastSlot, + }, + }, + }; + } + + // ========== Phase 3: Parsed Methods ========== + + async getParsedAccountInfo( + publicKey: PublicKey | string, + commitmentOrConfig?: LegacyCommitment | GetParsedAccountInfoConfig, + ): Promise | null>> { + const address = toKitAddressFromInput(publicKey); + const commitment = + typeof commitmentOrConfig === 'string' + ? normalizeCommitment(commitmentOrConfig) + : normalizeCommitment(commitmentOrConfig?.commitment); + const chosenCommitment = commitment ?? this.commitment ?? DEFAULT_COMMITMENT; + const minContextSlot = + typeof commitmentOrConfig === 'object' ? toBigInt(commitmentOrConfig?.minContextSlot) : undefined; + + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + encoding: 'jsonParsed', + }; + if (minContextSlot !== undefined) { + requestOptions.minContextSlot = minContextSlot; + } + + const response = await this.#client.runtime.rpc.getAccountInfo(address, requestOptions as never).send(); + const context = response.context as { slot: number | bigint }; + const value = response.value as unknown; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: value === null ? null : toParsedAccountInfo(value), + }; + } + + async getMultipleParsedAccounts( + publicKeys: PublicKey[], + commitmentOrConfig?: LegacyCommitment | GetMultipleParsedAccountsConfig, + ): Promise | null)[]>> { + const addresses = publicKeys.map((pk) => toKitAddressFromInput(pk)); + const commitment = + typeof commitmentOrConfig === 'string' + ? normalizeCommitment(commitmentOrConfig) + : normalizeCommitment(commitmentOrConfig?.commitment); + const chosenCommitment = commitment ?? this.commitment ?? DEFAULT_COMMITMENT; + const minContextSlot = + typeof commitmentOrConfig === 'object' ? toBigInt(commitmentOrConfig?.minContextSlot) : undefined; + + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + encoding: 'jsonParsed', + }; + if (minContextSlot !== undefined) { + requestOptions.minContextSlot = minContextSlot; + } + + const response = await this.#client.runtime.rpc.getMultipleAccounts(addresses, requestOptions as never).send(); + const context = response.context as { slot: number | bigint }; + const values = response.value as readonly (unknown | null)[]; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: values.map((account) => (account === null ? null : toParsedAccountInfo(account))), + }; + } + + async getParsedProgramAccounts( + programId: PublicKey, + configOrCommitment?: GetParsedProgramAccountsConfig | LegacyCommitment, + ): Promise< + { + account: AccountInfo; + pubkey: PublicKey; + }[] + > { + const programAddress = toKitAddressFromInput(programId); + const config = typeof configOrCommitment === 'string' ? { commitment: configOrCommitment } : configOrCommitment; + const chosenCommitment = normalizeCommitment(config?.commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + encoding: 'jsonParsed', + }; + + if (config?.filters) { + requestOptions.filters = config.filters; + } + if (config?.minContextSlot !== undefined) { + requestOptions.minContextSlot = toBigInt(config.minContextSlot); + } + + const response = await this.#client.runtime.rpc + .getProgramAccounts(programAddress, requestOptions as never) + .send(); + const accounts = response as unknown as readonly { account: unknown; pubkey: string }[]; + + return accounts.map((item) => ({ + account: toParsedAccountInfo(item.account), + pubkey: new PublicKey(item.pubkey), + })); + } + + async getParsedTokenAccountsByOwner( + ownerAddress: PublicKey, + filter: TokenAccountsFilter, + commitment?: LegacyCommitment, + ): Promise< + RpcResponseAndContext< + { + account: AccountInfo; + pubkey: PublicKey; + }[] + > + > { + const owner = toKitAddressFromInput(ownerAddress); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const filterArg = + 'mint' in filter + ? { mint: toKitAddressFromInput(filter.mint) } + : { programId: toKitAddressFromInput(filter.programId) }; + + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + encoding: 'jsonParsed', + }; + + const response = await this.#client.runtime.rpc + .getTokenAccountsByOwner(owner, filterArg, requestOptions as never) + .send(); + const context = (response as { context: { slot: number | bigint } }).context; + const accounts = (response as { value: readonly { account: unknown; pubkey: string }[] }).value; + + return { + context: { + slot: typeof context.slot === 'bigint' ? Number(context.slot) : context.slot, + }, + value: accounts.map((item) => ({ + account: toParsedAccountInfo(item.account) as AccountInfo, + pubkey: new PublicKey(item.pubkey), + })), + }; + } + + async getParsedTransaction( + signature: TransactionSignature, + commitmentOrConfig?: GetParsedTransactionConfig | Finality, + ): Promise { + const config = typeof commitmentOrConfig === 'string' ? { commitment: commitmentOrConfig } : commitmentOrConfig; + const commitment = normalizeCommitment(config?.commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const requestOptions: Record = { + commitment: commitment as KitCommitment, + encoding: 'jsonParsed', + maxSupportedTransactionVersion: config?.maxSupportedTransactionVersion ?? 0, + }; + + const response = await this.#client.runtime.rpc + .getTransaction(signature as Signature, requestOptions as never) + .send(); + + if (!response) { + return null; + } + + // Return the parsed transaction as-is from RPC (it's already in web3.js format) + return response as unknown as ParsedTransactionWithMeta; + } + + async getParsedTransactions( + signatures: TransactionSignature[], + commitmentOrConfig?: GetParsedTransactionConfig | Finality, + ): Promise<(ParsedTransactionWithMeta | null)[]> { + // Use Promise.all to fetch all transactions in parallel + const results = await Promise.all(signatures.map((sig) => this.getParsedTransaction(sig, commitmentOrConfig))); + return results; + } + + async getParsedBlock(slot: number, rawConfig?: GetParsedBlockConfig): Promise { + const commitment = normalizeCommitment(rawConfig?.commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + const requestOptions: Record = { + commitment: commitment as KitCommitment, + encoding: 'jsonParsed', + maxSupportedTransactionVersion: rawConfig?.maxSupportedTransactionVersion ?? 0, + transactionDetails: rawConfig?.transactionDetails ?? 'full', + }; + + if (rawConfig?.rewards !== undefined) { + requestOptions.rewards = rawConfig.rewards; + } + + const response = await this.#client.runtime.rpc.getBlock(BigInt(slot), requestOptions as never).send(); + + if (!response) { + throw new Error(`Block ${slot} not found`); + } + + const block = response as KitBlockResponse & { transactions?: readonly unknown[] }; + + // Return the parsed block as-is from RPC (it's already in web3.js format when using jsonParsed encoding) + return { + blockHeight: + block.blockHeight !== null && block.blockHeight !== undefined + ? typeof block.blockHeight === 'bigint' + ? Number(block.blockHeight) + : block.blockHeight + : null, + blockTime: + block.blockTime !== null && block.blockTime !== undefined + ? typeof block.blockTime === 'bigint' + ? Number(block.blockTime) + : block.blockTime + : null, + blockhash: block.blockhash, + parentSlot: typeof block.parentSlot === 'bigint' ? Number(block.parentSlot) : block.parentSlot, + previousBlockhash: block.previousBlockhash, + rewards: block.rewards as ParsedBlockResponse['rewards'], + transactions: block.transactions as ParsedBlockResponse['transactions'], + } as ParsedBlockResponse; + } + + // ========== WebSocket Subscription Methods ========== + + onAccountChange(publicKey: PublicKey, callback: AccountChangeCallback, commitment?: LegacyCommitment): number { + const id = this.#nextSubscriptionId++; + const address = toKitAddressFromInput(publicKey); + const abortController = new AbortController(); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + (async () => { + try { + const notifications = await this.#client.runtime.rpcSubscriptions + .accountNotifications(address, { commitment: chosenCommitment as KitCommitment }) + .subscribe({ abortSignal: abortController.signal }); + + for await (const notification of notifications) { + const value = notification.value as unknown; + const context: Context = { + slot: + typeof notification.context.slot === 'bigint' + ? Number(notification.context.slot) + : notification.context.slot, + }; + const accountInfo = toAccountInfo(fromKitAccount(value)); + callback(accountInfo, context); + } + } catch { + // Subscription ended or aborted + } + })(); + + this.#subscriptions.set(id, { abort: () => abortController.abort() }); + return id; + } + + removeAccountChangeListener(subscriptionId: number): Promise { + const entry = this.#subscriptions.get(subscriptionId); + if (entry) { + entry.abort(); + this.#subscriptions.delete(subscriptionId); + } + return Promise.resolve(); + } + + onProgramAccountChange( + programId: PublicKey, + callback: ProgramAccountChangeCallback, + commitment?: LegacyCommitment, + filters?: GetParsedProgramAccountsConfig['filters'], + ): number { + const id = this.#nextSubscriptionId++; + const programAddress = toKitAddressFromInput(programId); + const abortController = new AbortController(); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + (async () => { + try { + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + encoding: 'base64', + }; + if (filters) { + requestOptions.filters = filters; + } + + const notifications = await this.#client.runtime.rpcSubscriptions + .programNotifications(programAddress, requestOptions as never) + .subscribe({ abortSignal: abortController.signal }); + + for await (const notification of notifications) { + const value = notification.value as { account: unknown; pubkey: string }; + const context: Context = { + slot: + typeof notification.context.slot === 'bigint' + ? Number(notification.context.slot) + : notification.context.slot, + }; + const keyedAccountInfo: KeyedAccountInfo = { + accountId: new PublicKey(value.pubkey), + accountInfo: toAccountInfo(fromKitAccount(value.account)), + }; + callback(keyedAccountInfo, context); + } + } catch { + // Subscription ended or aborted + } + })(); + + this.#subscriptions.set(id, { abort: () => abortController.abort() }); + return id; + } + + removeProgramAccountChangeListener(subscriptionId: number): Promise { + const entry = this.#subscriptions.get(subscriptionId); + if (entry) { + entry.abort(); + this.#subscriptions.delete(subscriptionId); + } + return Promise.resolve(); + } + + onSignature( + signature: TransactionSignature, + callback: SignatureResultCallback, + commitment?: LegacyCommitment, + ): number { + const id = this.#nextSubscriptionId++; + const abortController = new AbortController(); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + (async () => { + try { + const notifications = await this.#client.runtime.rpcSubscriptions + .signatureNotifications(signature as Signature, { commitment: chosenCommitment as KitCommitment }) + .subscribe({ abortSignal: abortController.signal }); + + for await (const notification of notifications) { + const context: Context = { + slot: + typeof notification.context.slot === 'bigint' + ? Number(notification.context.slot) + : notification.context.slot, + }; + const result = notification.value as { err: TransactionError | null }; + callback({ err: result.err }, context); + } + } catch { + // Subscription ended or aborted + } + })(); + + this.#subscriptions.set(id, { abort: () => abortController.abort() }); + return id; + } + + onSignatureWithOptions( + signature: TransactionSignature, + callback: SignatureSubscriptionCallback, + options?: SignatureSubscriptionOptions, + ): number { + const id = this.#nextSubscriptionId++; + const abortController = new AbortController(); + const chosenCommitment = normalizeCommitment(options?.commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + (async () => { + try { + const requestOptions: Record = { + commitment: chosenCommitment as KitCommitment, + }; + if (options?.enableReceivedNotification) { + requestOptions.enableReceivedNotification = true; + } + + const notifications = await this.#client.runtime.rpcSubscriptions + .signatureNotifications(signature as Signature, requestOptions as never) + .subscribe({ abortSignal: abortController.signal }); + + for await (const notification of notifications) { + const context: Context = { + slot: + typeof notification.context.slot === 'bigint' + ? Number(notification.context.slot) + : notification.context.slot, + }; + const value = notification.value as { err?: TransactionError | null } | 'receivedSignature'; + if (value === 'receivedSignature') { + callback({ type: 'received' }, context); + } else { + callback({ err: value.err ?? null }, context); + } + } + } catch { + // Subscription ended or aborted + } + })(); + + this.#subscriptions.set(id, { abort: () => abortController.abort() }); + return id; + } + + removeSignatureListener(subscriptionId: number): Promise { + const entry = this.#subscriptions.get(subscriptionId); + if (entry) { + entry.abort(); + this.#subscriptions.delete(subscriptionId); + } + return Promise.resolve(); + } + + onSlotChange(callback: SlotChangeCallback): number { + const id = this.#nextSubscriptionId++; + const abortController = new AbortController(); + + (async () => { + try { + const notifications = await this.#client.runtime.rpcSubscriptions + .slotNotifications() + .subscribe({ abortSignal: abortController.signal }); + + for await (const notification of notifications) { + const slotInfo: SlotInfo = { + parent: + typeof notification.parent === 'bigint' ? Number(notification.parent) : notification.parent, + root: typeof notification.root === 'bigint' ? Number(notification.root) : notification.root, + slot: typeof notification.slot === 'bigint' ? Number(notification.slot) : notification.slot, + }; + callback(slotInfo); + } + } catch { + // Subscription ended or aborted + } + })(); + + this.#subscriptions.set(id, { abort: () => abortController.abort() }); + return id; + } + + removeSlotChangeListener(subscriptionId: number): Promise { + const entry = this.#subscriptions.get(subscriptionId); + if (entry) { + entry.abort(); + this.#subscriptions.delete(subscriptionId); + } + return Promise.resolve(); + } + + onSlotUpdate(callback: SlotUpdateCallback): number { + const id = this.#nextSubscriptionId++; + const abortController = new AbortController(); + + (async () => { + try { + // Note: slotsUpdatesNotifications may not be available on all RPC endpoints + const rpcSubscriptions = this.#client.runtime.rpcSubscriptions as unknown as { + slotsUpdatesNotifications: () => { + subscribe: (options: { abortSignal: AbortSignal }) => Promise< + AsyncIterable<{ + slot: number | bigint; + timestamp: number | bigint; + type: string; + parent?: number | bigint; + stats?: unknown; + err?: unknown; + }> + >; + }; + }; + + const notifications = await rpcSubscriptions + .slotsUpdatesNotifications() + .subscribe({ abortSignal: abortController.signal }); + + for await (const notification of notifications) { + const slotUpdate = { + slot: typeof notification.slot === 'bigint' ? Number(notification.slot) : notification.slot, + timestamp: + typeof notification.timestamp === 'bigint' + ? Number(notification.timestamp) + : notification.timestamp, + type: notification.type, + } as SlotUpdate; + + if (notification.parent !== undefined) { + (slotUpdate as { parent?: number }).parent = + typeof notification.parent === 'bigint' ? Number(notification.parent) : notification.parent; + } + if (notification.stats !== undefined) { + (slotUpdate as { stats?: unknown }).stats = notification.stats; + } + if (notification.err !== undefined) { + (slotUpdate as { err?: unknown }).err = notification.err; + } + callback(slotUpdate); + } + } catch { + // Subscription ended or aborted + } + })(); + + this.#subscriptions.set(id, { abort: () => abortController.abort() }); + return id; + } + + removeSlotUpdateListener(subscriptionId: number): Promise { + const entry = this.#subscriptions.get(subscriptionId); + if (entry) { + entry.abort(); + this.#subscriptions.delete(subscriptionId); + } + return Promise.resolve(); + } + + onRootChange(callback: RootChangeCallback): number { + const id = this.#nextSubscriptionId++; + const abortController = new AbortController(); + + (async () => { + try { + const notifications = await this.#client.runtime.rpcSubscriptions + .rootNotifications() + .subscribe({ abortSignal: abortController.signal }); + + for await (const notification of notifications) { + const root = typeof notification === 'bigint' ? Number(notification) : notification; + callback(root as number); + } + } catch { + // Subscription ended or aborted + } + })(); + + this.#subscriptions.set(id, { abort: () => abortController.abort() }); + return id; + } + + removeRootChangeListener(subscriptionId: number): Promise { + const entry = this.#subscriptions.get(subscriptionId); + if (entry) { + entry.abort(); + this.#subscriptions.delete(subscriptionId); + } + return Promise.resolve(); + } + + onLogs(filter: LogsFilter | PublicKey, callback: LogsCallback, commitment?: LegacyCommitment): number { + const id = this.#nextSubscriptionId++; + const abortController = new AbortController(); + const chosenCommitment = normalizeCommitment(commitment) ?? this.commitment ?? DEFAULT_COMMITMENT; + + // Convert filter to Kit format + let logsFilter: unknown; + if (filter instanceof PublicKey) { + logsFilter = { mentions: [toKitAddressFromInput(filter)] }; + } else if (filter === 'all') { + logsFilter = 'all'; + } else if (filter === 'allWithVotes') { + logsFilter = 'allWithVotes'; + } else if ('mentions' in filter) { + logsFilter = { mentions: filter.mentions.map((m) => toKitAddressFromInput(m)) }; + } else { + logsFilter = filter; + } + + (async () => { + try { + const notifications = await this.#client.runtime.rpcSubscriptions + .logsNotifications(logsFilter as never, { commitment: chosenCommitment as KitCommitment }) + .subscribe({ abortSignal: abortController.signal }); + + for await (const notification of notifications) { + const context: Context = { + slot: + typeof notification.context.slot === 'bigint' + ? Number(notification.context.slot) + : notification.context.slot, + }; + const value = notification.value as { err: unknown; logs: readonly string[]; signature: string }; + const logs: Logs = { + err: value.err as TransactionError | null, + logs: [...value.logs], + signature: value.signature, + }; + callback(logs, context); + } + } catch { + // Subscription ended or aborted + } + })(); + + this.#subscriptions.set(id, { abort: () => abortController.abort() }); + return id; + } + + removeOnLogsListener(subscriptionId: number): Promise { + const entry = this.#subscriptions.get(subscriptionId); + if (entry) { + entry.abort(); + this.#subscriptions.delete(subscriptionId); + } + return Promise.resolve(); + } } diff --git a/packages/web3-compat/src/connection/adapters.ts b/packages/web3-compat/src/connection/adapters.ts new file mode 100644 index 0000000..b46e885 --- /dev/null +++ b/packages/web3-compat/src/connection/adapters.ts @@ -0,0 +1,241 @@ +/** + * Adapter functions for converting between @solana/web3.js and @solana/kit types. + */ + +import type { Address } from '@solana/addresses'; +import type { Base64EncodedWireTransaction } from '@solana/transactions'; +import { + type AccountInfo, + type DataSlice, + type Commitment as LegacyCommitment, + type ParsedAccountData, + PublicKey, + Transaction, + VersionedTransaction, +} from '@solana/web3.js'; + +import { toAddress as toKitAddress } from '../bridges'; +import type { KitParsedAccountData, NormalizedCommitment, RawTransactionInput, RpcAccount } from '../types'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Default commitment level when none specified. + */ +export const DEFAULT_COMMITMENT: NormalizedCommitment = 'confirmed'; + +/** + * Default configuration for transaction simulation. + */ +export const DEFAULT_SIMULATION_CONFIG = Object.freeze({ + encoding: 'base64' as const, + replaceRecentBlockhash: true as const, + sigVerify: false as const, +}); + +// ============================================================================ +// Commitment Conversion +// ============================================================================ + +/** + * Normalizes legacy commitment levels to modern equivalents. + * + * Maps deprecated commitment aliases: + * - 'recent' → 'processed' + * - 'singleGossip' → 'processed' + * - 'single' → 'confirmed' + * - 'max' → 'finalized' + */ +export function normalizeCommitment(commitment?: LegacyCommitment | null): NormalizedCommitment | undefined { + if (commitment === undefined || commitment === null) { + return undefined; + } + if (commitment === 'recent') { + return 'processed'; + } + if (commitment === 'singleGossip') { + return 'processed'; + } + if (commitment === 'single') { + return 'confirmed'; + } + if (commitment === 'max') { + return 'finalized'; + } + return commitment as NormalizedCommitment; +} + +// ============================================================================ +// Number/BigInt Conversion +// ============================================================================ + +/** + * Converts number or bigint to bigint, handling undefined. + */ +export function toBigInt(value: number | bigint | undefined): bigint | undefined { + if (value === undefined) return undefined; + return typeof value === 'bigint' ? value : BigInt(Math.trunc(value)); +} + +/** + * Converts bigint to number safely. + */ +export function toNumber(value: number | bigint): number { + return typeof value === 'number' ? value : Number(value); +} + +// ============================================================================ +// Address Conversion +// ============================================================================ + +/** + * Converts PublicKey or string to @solana/kit Address. + */ +export function toKitAddressFromInput(input: PublicKey | string): Address { + return toKitAddress(input instanceof PublicKey ? input : input); +} + +// ============================================================================ +// Account Conversion +// ============================================================================ + +/** + * Converts RPC account data to web3.js AccountInfo format. + */ +export function toAccountInfo(info: RpcAccount, dataSlice?: DataSlice): AccountInfo { + const { data, executable, lamports, owner, rentEpoch } = info; + const [content, encoding] = Array.isArray(data) ? data : [data, 'base64']; + let buffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content); + if (dataSlice) { + const start = dataSlice.offset ?? 0; + const end = start + (dataSlice.length ?? buffer.length); + buffer = buffer.subarray(start, end); + } + return { + data: buffer, + executable, + lamports: typeof lamports === 'number' ? lamports : Number(lamports), + owner: new PublicKey(owner), + rentEpoch: typeof rentEpoch === 'number' ? rentEpoch : Number(rentEpoch), + }; +} + +/** + * Converts @solana/kit account response to RpcAccount format. + */ +export function fromKitAccount(value: unknown): RpcAccount { + const account = (value ?? {}) as Record; + const data = account.data as string | readonly [string, string] | undefined; + const lamports = account.lamports as number | bigint | undefined; + const ownerValue = account.owner as unknown; + const rentEpoch = account.rentEpoch as number | bigint | undefined; + const owner = + typeof ownerValue === 'string' + ? ownerValue + : ownerValue instanceof PublicKey + ? ownerValue.toBase58() + : typeof ownerValue === 'object' && ownerValue !== null && 'toString' in ownerValue + ? String(ownerValue) + : '11111111111111111111111111111111'; + return { + data: data ?? ['', 'base64'], + executable: Boolean(account.executable), + lamports: lamports ?? 0, + owner, + rentEpoch: rentEpoch ?? 0, + }; +} + +/** + * Converts kit parsed account data to web3.js ParsedAccountData. + */ +export function toParsedAccountData(kitParsed: KitParsedAccountData): ParsedAccountData { + return { + parsed: kitParsed.data.parsed, + program: kitParsed.data.program, + space: kitParsed.data.space, + }; +} + +/** + * Converts kit account to web3.js AccountInfo with parsed or buffer data. + */ +export function toParsedAccountInfo(kitAccount: unknown): AccountInfo { + const account = (kitAccount ?? {}) as Record; + const executable = Boolean(account.executable); + const lamports = account.lamports as number | bigint; + const ownerValue = account.owner as unknown; + const rentEpoch = account.rentEpoch as number | bigint | undefined; + + const owner = + typeof ownerValue === 'string' + ? new PublicKey(ownerValue) + : ownerValue instanceof PublicKey + ? ownerValue + : typeof ownerValue === 'object' && ownerValue !== null && 'toString' in ownerValue + ? new PublicKey(String(ownerValue)) + : new PublicKey('11111111111111111111111111111111'); + + const data = account.data as unknown; + + // Check if it's parsed data (object with parsed, program, space) + if (typeof data === 'object' && data !== null && 'parsed' in data && 'program' in data) { + return { + data: toParsedAccountData({ + data: data as KitParsedAccountData['data'], + executable, + lamports, + owner: owner.toBase58(), + rentEpoch: rentEpoch ?? 0, + }), + executable, + lamports: typeof lamports === 'number' ? lamports : Number(lamports), + owner, + rentEpoch: + rentEpoch !== undefined ? (typeof rentEpoch === 'number' ? rentEpoch : Number(rentEpoch)) : undefined, + }; + } + + // Otherwise treat as raw buffer + const rawData = data as string | readonly [string, string] | undefined; + const [content, encoding] = Array.isArray(rawData) ? rawData : [rawData ?? '', 'base64']; + const buffer = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content); + + return { + data: buffer, + executable, + lamports: typeof lamports === 'number' ? lamports : Number(lamports), + owner, + rentEpoch: + rentEpoch !== undefined ? (typeof rentEpoch === 'number' ? rentEpoch : Number(rentEpoch)) : undefined, + }; +} + +// ============================================================================ +// Transaction Conversion +// ============================================================================ + +/** + * Converts raw transaction input to base64-encoded wire format. + */ +export function toBase64WireTransaction( + raw: RawTransactionInput | Transaction | VersionedTransaction, +): Base64EncodedWireTransaction { + if (raw instanceof Transaction || raw instanceof VersionedTransaction) { + const bytes = raw.serialize({ + requireAllSignatures: false, + verifySignatures: false, + }); + return Buffer.from(bytes).toString('base64') as Base64EncodedWireTransaction; + } + if (raw instanceof Uint8Array) { + return Buffer.from(raw).toString('base64') as Base64EncodedWireTransaction; + } + if (raw instanceof Buffer) { + return raw.toString('base64') as Base64EncodedWireTransaction; + } + const uint8 = Uint8Array.from(raw as number[]); + return Buffer.from(uint8).toString('base64') as Base64EncodedWireTransaction; +} diff --git a/packages/web3-compat/src/connection/index.ts b/packages/web3-compat/src/connection/index.ts new file mode 100644 index 0000000..0a8f5e4 --- /dev/null +++ b/packages/web3-compat/src/connection/index.ts @@ -0,0 +1,7 @@ +/** + * Connection module exports. + * + * Provides adapters and utilities for the Connection class. + */ + +export * from './adapters'; diff --git a/packages/web3-compat/src/index.ts b/packages/web3-compat/src/index.ts index d064d76..c0073ba 100644 --- a/packages/web3-compat/src/index.ts +++ b/packages/web3-compat/src/index.ts @@ -9,4 +9,5 @@ export { export { fromWeb3Instruction, toAddress, toKitSigner, toPublicKey, toWeb3Instruction } from './bridges'; export { Connection } from './connection'; export { SystemProgram } from './programs/system-program'; +// Internal types are not re-exported to avoid conflicts with @solana/web3.js export { compileFromCompat, LAMPORTS_PER_SOL, sendAndConfirmTransaction } from './utils'; diff --git a/packages/web3-compat/src/types.ts b/packages/web3-compat/src/types.ts new file mode 100644 index 0000000..c712f4b --- /dev/null +++ b/packages/web3-compat/src/types.ts @@ -0,0 +1,359 @@ +/** + * Type definitions for @solana/web3-compat Connection class. + * + * These types bridge @solana/web3.js API with @solana/kit internals. + */ + +import type { + AccountInfo, + ConnectionConfig, + Context, + DataSlice, + Finality, + KeyedAccountInfo, + Commitment as LegacyCommitment, + Logs, + PublicKey, + SignatureResult, + SignatureStatusConfig, + SlotInfo, + SlotUpdate, +} from '@solana/web3.js'; + +// ============================================================================ +// Commitment Types +// ============================================================================ + +/** + * Normalized commitment level compatible with @solana/kit. + */ +export type NormalizedCommitment = 'processed' | 'confirmed' | 'finalized'; + +// ============================================================================ +// RPC Response Types +// ============================================================================ + +/** + * Context returned with RPC responses. + */ +export type RpcContext = Readonly<{ + apiVersion?: string; + slot: number; +}>; + +/** + * Generic RPC response wrapper with context. + */ +export type RpcResponseWithContext = Readonly<{ + context: RpcContext; + value: T; +}>; + +// ============================================================================ +// Account Types +// ============================================================================ + +/** + * Configuration for getAccountInfo calls. + */ +export type AccountInfoConfig = Readonly<{ + commitment?: LegacyCommitment; + dataSlice?: DataSlice; + encoding?: 'base64'; + minContextSlot?: number; +}>; + +/** + * Configuration for getProgramAccounts calls. + */ +export type ProgramAccountsConfig = Readonly<{ + commitment?: LegacyCommitment; + dataSlice?: DataSlice; + encoding?: 'base64' | 'base64+zstd'; + filters?: ReadonlyArray; + minContextSlot?: number; + withContext?: boolean; +}>; + +/** + * Configuration for getMultipleAccountsInfo calls. + */ +export type GetMultipleAccountsConfig = Readonly<{ + commitment?: LegacyCommitment; + dataSlice?: DataSlice; + minContextSlot?: number; +}>; + +/** + * Configuration for getParsedAccountInfo calls. + */ +export type GetParsedAccountInfoConfig = Readonly<{ + commitment?: LegacyCommitment; + minContextSlot?: number; +}>; + +/** + * Configuration for getMultipleParsedAccounts calls. + */ +export type GetMultipleParsedAccountsConfig = Readonly<{ + commitment?: LegacyCommitment; + minContextSlot?: number; +}>; + +/** + * Raw RPC account data structure. + */ +export type RpcAccount = Readonly<{ + data: readonly [string, string] | string; + executable: boolean; + lamports: number | bigint; + owner: string; + rentEpoch: number | bigint; +}>; + +/** + * Program account with pubkey from RPC wire format. + */ +export type ProgramAccountWire = Readonly<{ + account: RpcAccount; + pubkey: string; +}>; + +/** + * Program accounts response with context. + */ +export type ProgramAccountsWithContext = Readonly<{ + context: Readonly<{ + apiVersion?: string; + slot: number | bigint; + }>; + value: readonly ProgramAccountWire[]; +}>; + +// ============================================================================ +// Token Types +// ============================================================================ + +/** + * Filter for token account queries. + */ +export type TokenAccountsFilter = { mint: PublicKey } | { programId: PublicKey }; + +/** + * Configuration for getTokenAccountsByOwner calls. + */ +export type GetTokenAccountsByOwnerConfig = Readonly<{ + commitment?: LegacyCommitment; + encoding?: 'base64' | 'jsonParsed'; + minContextSlot?: number; +}>; + +// ============================================================================ +// Transaction Types +// ============================================================================ + +/** + * Input types accepted for raw transaction data. + * Note: Transaction and VersionedTransaction are handled separately in adapters + * to avoid circular import issues with @solana/web3.js. + */ +export type RawTransactionInput = number[] | Uint8Array | Buffer; + +/** + * Configuration for getTransaction calls. + */ +export type GetTransactionConfig = Readonly<{ + commitment?: Finality; + maxSupportedTransactionVersion?: number; +}>; + +/** + * Configuration for getParsedTransaction calls. + */ +export type GetParsedTransactionConfig = Readonly<{ + commitment?: Finality; + maxSupportedTransactionVersion?: number; +}>; + +/** + * Configuration for getSignatureStatuses with commitment. + */ +export type SignatureStatusConfigWithCommitment = SignatureStatusConfig & { + commitment?: LegacyCommitment; +}; + +// ============================================================================ +// Block Types +// ============================================================================ + +/** + * Configuration for getBlock calls. + */ +export type GetBlockConfig = Readonly<{ + commitment?: Finality; + maxSupportedTransactionVersion?: number; + rewards?: boolean; + transactionDetails?: 'full' | 'accounts' | 'signatures' | 'none'; +}>; + +/** + * Configuration for getParsedBlock calls. + */ +export type GetParsedBlockConfig = Readonly<{ + commitment?: Finality; + maxSupportedTransactionVersion?: number; + rewards?: boolean; + transactionDetails?: 'full' | 'accounts' | 'signatures' | 'none'; +}>; + +// ============================================================================ +// Connection Configuration Types +// ============================================================================ + +/** + * Input types for commitment configuration. + */ +export type ConnectionCommitmentInput = + | LegacyCommitment + | (ConnectionConfig & { + commitment?: LegacyCommitment; + }) + | undefined; + +// ============================================================================ +// Subscription Types +// ============================================================================ + +/** + * Internal subscription tracking entry. + */ +export type SubscriptionEntry = { + abort: () => void; +}; + +/** + * Callback for account change notifications. + */ +export type AccountChangeCallback = (accountInfo: AccountInfo, context: Context) => void; + +/** + * Callback for program account change notifications. + */ +export type ProgramAccountChangeCallback = (keyedAccountInfo: KeyedAccountInfo, context: Context) => void; + +/** + * Callback for slot change notifications. + */ +export type SlotChangeCallback = (slotInfo: SlotInfo) => void; + +/** + * Callback for slot update notifications. + */ +export type SlotUpdateCallback = (slotUpdate: SlotUpdate) => void; + +/** + * Callback for signature result notifications. + */ +export type SignatureResultCallback = (signatureResult: SignatureResult, context: Context) => void; + +/** + * Callback for signature subscription notifications (includes received). + */ +export type SignatureSubscriptionCallback = ( + notification: SignatureResult | { type: 'received' }, + context: Context, +) => void; + +/** + * Callback for root change notifications. + */ +export type RootChangeCallback = (root: number) => void; + +/** + * Callback for logs notifications. + */ +export type LogsCallback = (logs: Logs, context: Context) => void; + +/** + * Filter for logs subscriptions. + */ +export type LogsFilter = 'all' | 'allWithVotes' | { mentions: string[] }; + +// ============================================================================ +// Kit Internal Types (for type conversion) +// ============================================================================ + +/** + * Parsed account data structure from @solana/kit. + */ +export type KitParsedAccountData = { + data: { + parsed: unknown; + program: string; + space: number; + }; + executable: boolean; + lamports: number | bigint; + owner: string; + rentEpoch: number | bigint; +}; + +/** + * Transaction metadata from @solana/kit. + */ +export type KitTransactionMeta = { + err: unknown; + fee: number | bigint; + innerInstructions: readonly unknown[] | null; + loadedAddresses?: { + readonly: readonly string[]; + writable: readonly string[]; + }; + logMessages: readonly string[] | null; + postBalances: readonly (number | bigint)[]; + postTokenBalances: readonly unknown[] | null; + preBalances: readonly (number | bigint)[]; + preTokenBalances: readonly unknown[] | null; + rewards: readonly unknown[] | null; + computeUnitsConsumed?: number | bigint; +}; + +/** + * Transaction response from @solana/kit. + */ +export type KitTransactionResponse = { + blockTime: number | bigint | null; + meta: KitTransactionMeta | null; + slot: number | bigint; + transaction: { + message: unknown; + signatures: readonly string[]; + }; + version?: 'legacy' | 0; +}; + +/** + * Signature info from @solana/kit. + */ +export type KitSignatureInfo = { + blockTime: number | bigint | null; + confirmationStatus: string | null; + err: unknown; + memo: string | null; + signature: string; + slot: number | bigint; +}; + +/** + * Block response from @solana/kit. + */ +export type KitBlockResponse = { + blockHeight: number | bigint | null; + blockTime: number | bigint | null; + blockhash: string; + parentSlot: number | bigint; + previousBlockhash: string; + rewards?: readonly unknown[]; + transactions?: readonly unknown[]; + signatures?: readonly string[]; +}; diff --git a/packages/web3-compat/test/connection.test.ts b/packages/web3-compat/test/connection.test.ts index 0877534..7d5b7eb 100644 --- a/packages/web3-compat/test/connection.test.ts +++ b/packages/web3-compat/test/connection.test.ts @@ -9,14 +9,13 @@ import { import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@solana/client', () => ({ - createSolanaRpcClient: vi.fn(), + createClient: vi.fn(), })); -import { createSolanaRpcClient } from '@solana/client'; +import { createClient } from '@solana/client'; import { Connection } from '../src'; const MOCK_ENDPOINT = 'http://localhost:8899'; -const MOCK_WS_ENDPOINT = 'ws://localhost:8900'; function createPlan(value: T) { return { @@ -29,8 +28,23 @@ type MockRpc = { getLatestBlockhash: MockFn; getBalance: MockFn; getAccountInfo: MockFn; + getBlock: MockFn; + getBlockHeight: MockFn; + getBlocks: MockFn; + getBlockTime: MockFn; + getFeeForMessage: MockFn; + getMultipleAccounts: MockFn; getProgramAccounts: MockFn; + getRecentPrioritizationFees: MockFn; getSignatureStatuses: MockFn; + getSignaturesForAddress: MockFn; + getSlot: MockFn; + getTokenAccountBalance: MockFn; + getTokenAccountsByOwner: MockFn; + getTransaction: MockFn; + getMinimumBalanceForRentExemption: MockFn; + isBlockhashValid: MockFn; + requestAirdrop: MockFn; sendTransaction: MockFn; simulateTransaction: MockFn; }; @@ -73,6 +87,49 @@ beforeEach(() => { }, }), ), + getBlock: vi.fn(() => + createPlan({ + blockHeight: 12345n, + blockTime: 1700000000n, + blockhash: 'MockBlockhash11111111111111111111111111111', + parentSlot: 99n, + previousBlockhash: 'PrevBlockhash111111111111111111111111111', + rewards: [], + transactions: [], + signatures: ['MockSig1', 'MockSig2'], + }), + ), + getBlockHeight: vi.fn(() => createPlan(12345n)), + getBlocks: vi.fn(() => createPlan([100n, 101n, 102n, 103n])), + getBlockTime: vi.fn(() => createPlan(1700000000n)), + getFeeForMessage: vi.fn(() => + createPlan({ + context: { slot: 100n }, + value: 5000n, + }), + ), + getMultipleAccounts: vi.fn(() => + createPlan({ + context: { slot: 77n }, + value: [ + { + lamports: 1234n, + owner: accountOwner.toBase58(), + data: [Buffer.from('mock-data-1').toString('base64'), 'base64'], + executable: false, + rentEpoch: 88n, + }, + null, + { + lamports: 5678n, + owner: accountOwner.toBase58(), + data: [Buffer.from('mock-data-2').toString('base64'), 'base64'], + executable: true, + rentEpoch: 99n, + }, + ], + }), + ), getProgramAccounts: vi.fn(() => createPlan([ { @@ -87,6 +144,12 @@ beforeEach(() => { }, ]), ), + getRecentPrioritizationFees: vi.fn(() => + createPlan([ + { prioritizationFee: 100n, slot: 1000n }, + { prioritizationFee: 200n, slot: 1001n }, + ]), + ), getSignatureStatuses: vi.fn(() => createPlan({ context: { slot: 444n }, @@ -100,6 +163,86 @@ beforeEach(() => { ], }), ), + getSignaturesForAddress: vi.fn(() => + createPlan([ + { + blockTime: 1700000000n, + confirmationStatus: 'finalized', + err: null, + memo: null, + signature: 'MockSignature1111111111111111111111111111111111', + slot: 200n, + }, + { + blockTime: 1699999000n, + confirmationStatus: 'confirmed', + err: null, + memo: 'test memo', + signature: 'MockSignature2222222222222222222222222222222222', + slot: 199n, + }, + ]), + ), + getSlot: vi.fn(() => createPlan(12345n)), + getTokenAccountBalance: vi.fn(() => + createPlan({ + context: { slot: 100n }, + value: { + amount: '1000000000', + decimals: 9, + uiAmount: 1.0, + uiAmountString: '1', + }, + }), + ), + getTokenAccountsByOwner: vi.fn(() => + createPlan({ + context: { slot: 100n }, + value: [ + { + pubkey: programAccountPubkey.toBase58(), + account: { + lamports: 2039280n, + owner: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + data: [Buffer.from('token-account-data').toString('base64'), 'base64'], + executable: false, + rentEpoch: 88n, + }, + }, + ], + }), + ), + getTransaction: vi.fn(() => + createPlan({ + blockTime: 1700000000n, + meta: { + err: null, + fee: 5000n, + innerInstructions: [], + logMessages: ['Program log: success'], + postBalances: [100000000n, 50000000n], + postTokenBalances: [], + preBalances: [100005000n, 50000000n], + preTokenBalances: [], + rewards: [], + computeUnitsConsumed: 1000n, + }, + slot: 200n, + transaction: { + message: {}, + signatures: ['MockSignature1111111111111111111111111111111111'], + }, + version: 'legacy', + }), + ), + getMinimumBalanceForRentExemption: vi.fn(() => createPlan(2039280n)), + isBlockhashValid: vi.fn(() => + createPlan({ + context: { slot: 100n }, + value: true, + }), + ), + requestAirdrop: vi.fn(() => createPlan('MockAirdropSignature11111111111111111111111111')), sendTransaction: vi.fn(() => createPlan('MockSignature1111111111111111111111111111111111')), simulateTransaction: vi.fn(() => createPlan({ @@ -112,14 +255,11 @@ beforeEach(() => { ), }; - (createSolanaRpcClient as unknown as ReturnType).mockReturnValue({ - commitment: 'confirmed', - endpoint: MOCK_ENDPOINT, - rpc: mockRpc, - rpcSubscriptions: {}, - sendAndConfirmTransaction: vi.fn(), - simulateTransaction: vi.fn(), - websocketEndpoint: MOCK_WS_ENDPOINT, + (createClient as unknown as ReturnType).mockReturnValue({ + runtime: { + rpc: mockRpc, + rpcSubscriptions: {}, + }, }); }); @@ -188,8 +328,7 @@ describe('Connection', () => { const connection = new Connection(MOCK_ENDPOINT); const response = await connection.getSignatureStatuses(['MockSignature1111111111111111111111111111111111']); expect(mockRpc.getSignatureStatuses).toHaveBeenCalledWith(['MockSignature1111111111111111111111111111111111'], { - commitment: 'confirmed', - searchTransactionHistory: undefined, + searchTransactionHistory: false, }); expect(response.context.slot).toBe(444); expect(response.value[0]?.confirmations).toBe(2); @@ -222,7 +361,6 @@ describe('Connection', () => { 'processed', ); expect(mockRpc.getSignatureStatuses).toHaveBeenCalledWith(['MockSignature1111111111111111111111111111111111'], { - commitment: 'processed', searchTransactionHistory: true, }); expect(result.value?.err).toBeNull(); @@ -268,6 +406,253 @@ describe('Connection', () => { await connection.simulateTransaction(tx); expect(mockRpc.simulateTransaction).toHaveBeenCalledTimes(1); }); + + it('getMultipleAccountsInfo returns array with nulls for missing accounts', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const pubkeys = [Keypair.generate().publicKey, Keypair.generate().publicKey, Keypair.generate().publicKey]; + const result = await connection.getMultipleAccountsInfo(pubkeys, { commitment: 'processed' }); + + expect(mockRpc.getMultipleAccounts).toHaveBeenCalledWith( + pubkeys.map((pk) => pk.toBase58()), + expect.objectContaining({ + commitment: 'processed', + encoding: 'base64', + }), + ); + expect(result).toHaveLength(3); + expect(result[0]?.lamports).toBe(1234); + expect(result[0]?.data.equals(Buffer.from('mock-data-1'))).toBe(true); + expect(result[1]).toBeNull(); + expect(result[2]?.lamports).toBe(5678); + expect(result[2]?.executable).toBe(true); + }); + + it('getMultipleAccountsInfoAndContext returns context with value array', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const pubkeys = [Keypair.generate().publicKey, Keypair.generate().publicKey]; + const result = await connection.getMultipleAccountsInfoAndContext(pubkeys); + + expect(result.context.slot).toBe(77); + expect(result.value).toHaveLength(3); + }); + + it('getTokenAccountsByOwner returns token accounts with context', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const owner = Keypair.generate().publicKey; + const mint = Keypair.generate().publicKey; + + const result = await connection.getTokenAccountsByOwner(owner, { mint }, { commitment: 'confirmed' }); + + expect(mockRpc.getTokenAccountsByOwner).toHaveBeenCalledWith( + owner.toBase58(), + { mint: mint.toBase58() }, + expect.objectContaining({ + commitment: 'confirmed', + encoding: 'base64', + }), + ); + expect(result.context.slot).toBe(100); + expect(result.value).toHaveLength(1); + expect(result.value[0].pubkey instanceof PublicKey).toBe(true); + }); + + it('getTokenAccountBalance returns token amount with context', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const tokenAccount = Keypair.generate().publicKey; + + const result = await connection.getTokenAccountBalance(tokenAccount, 'processed'); + + expect(mockRpc.getTokenAccountBalance).toHaveBeenCalledWith(tokenAccount.toBase58(), { + commitment: 'processed', + }); + expect(result.context.slot).toBe(100); + expect(result.value.amount).toBe('1000000000'); + expect(result.value.decimals).toBe(9); + expect(result.value.uiAmount).toBe(1.0); + }); + + it('getTransaction returns transaction details with numeric fields', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getTransaction('MockSignature1111111111111111111111111111111111', { + maxSupportedTransactionVersion: 0, + }); + + expect(mockRpc.getTransaction).toHaveBeenCalledWith( + 'MockSignature1111111111111111111111111111111111', + expect.objectContaining({ + commitment: 'confirmed', + encoding: 'json', + maxSupportedTransactionVersion: 0, + }), + ); + expect(result).not.toBeNull(); + expect(result?.slot).toBe(200); + expect(result?.blockTime).toBe(1700000000); + expect(result?.meta?.fee).toBe(5000); + expect(result?.meta?.computeUnitsConsumed).toBe(1000); + }); + + it('getSignaturesForAddress returns signature info array', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const address = Keypair.generate().publicKey; + + const result = await connection.getSignaturesForAddress(address, { limit: 10 }, 'finalized'); + + expect(mockRpc.getSignaturesForAddress).toHaveBeenCalledWith( + address.toBase58(), + expect.objectContaining({ + commitment: 'finalized', + limit: 10, + }), + ); + expect(result).toHaveLength(2); + expect(result[0].signature).toBe('MockSignature1111111111111111111111111111111111'); + expect(result[0].slot).toBe(200); + expect(result[0].blockTime).toBe(1700000000); + expect(result[1].memo).toBe('test memo'); + }); + + it('getSlot returns number slot', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getSlot('processed'); + + expect(mockRpc.getSlot).toHaveBeenCalledWith( + expect.objectContaining({ + commitment: 'processed', + }), + ); + expect(result).toBe(12345); + }); + + it('requestAirdrop returns signature', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const to = Keypair.generate().publicKey; + + const result = await connection.requestAirdrop(to, 1000000000); + + expect(result).toBe('MockAirdropSignature11111111111111111111111111'); + }); + + it('getMinimumBalanceForRentExemption returns number lamports', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getMinimumBalanceForRentExemption(165, 'confirmed'); + + expect(mockRpc.getMinimumBalanceForRentExemption).toHaveBeenCalledWith(165n, { + commitment: 'confirmed', + }); + expect(result).toBe(2039280); + }); + + // Phase 2 tests + it('getBlock returns block with numeric fields', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getBlock(100, { maxSupportedTransactionVersion: 0 }); + + expect(mockRpc.getBlock).toHaveBeenCalledWith( + 100n, + expect.objectContaining({ + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }), + ); + expect(result).not.toBeNull(); + expect(result?.blockHeight).toBe(12345); + expect(result?.blockTime).toBe(1700000000); + expect(result?.parentSlot).toBe(99); + }); + + it('getBlockTime returns number timestamp', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getBlockTime(100); + + expect(mockRpc.getBlockTime).toHaveBeenCalledWith(100n); + expect(result).toBe(1700000000); + }); + + it('getBlockHeight returns number height', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getBlockHeight('finalized'); + + expect(mockRpc.getBlockHeight).toHaveBeenCalledWith({ + commitment: 'finalized', + }); + expect(result).toBe(12345); + }); + + it('getBlocks returns number array', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getBlocks(100, 103, 'confirmed'); + + expect(mockRpc.getBlocks).toHaveBeenCalledWith( + 100n, + 103n, + expect.objectContaining({ commitment: 'confirmed' }), + ); + expect(result).toEqual([100, 101, 102, 103]); + }); + + it('isBlockhashValid returns context with boolean', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.isBlockhashValid('MockBlockhash11111111111111111111111111111', 'processed'); + + expect(mockRpc.isBlockhashValid).toHaveBeenCalledWith('MockBlockhash11111111111111111111111111111', { + commitment: 'processed', + }); + expect(result.context.slot).toBe(100); + expect(result.value).toBe(true); + }); + + it('getFeeForMessage returns fee with context', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getFeeForMessage('base64EncodedMessage', 'confirmed'); + + expect(mockRpc.getFeeForMessage).toHaveBeenCalledWith('base64EncodedMessage', { commitment: 'confirmed' }); + expect(result.context.slot).toBe(100); + expect(result.value).toBe(5000); + }); + + it('getRecentPrioritizationFees returns array of fees', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getRecentPrioritizationFees(); + + expect(mockRpc.getRecentPrioritizationFees).toHaveBeenCalled(); + expect(result).toHaveLength(2); + expect(result[0].prioritizationFee).toBe(100); + expect(result[0].slot).toBe(1000); + }); + + it('getAccountInfoAndContext returns account with context', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const pubkey = Keypair.generate().publicKey; + const result = await connection.getAccountInfoAndContext(pubkey, 'processed'); + + expect(mockRpc.getAccountInfo).toHaveBeenCalledWith( + pubkey.toBase58(), + expect.objectContaining({ commitment: 'processed' }), + ); + expect(result.context.slot).toBe(77); + expect(result.value?.lamports).toBe(1234); + }); + + it('getBalanceAndContext returns balance with context', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const pubkey = Keypair.generate().publicKey; + const result = await connection.getBalanceAndContext(pubkey, 'finalized'); + + expect(mockRpc.getBalance).toHaveBeenCalledWith(pubkey.toBase58(), { commitment: 'finalized' }); + expect(result.context.slot).toBe(55); + expect(result.value).toBe(5000); + }); + + it('getLatestBlockhashAndContext returns blockhash with context', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getLatestBlockhashAndContext({ commitment: 'processed' }); + + expect(mockRpc.getLatestBlockhash).toHaveBeenCalledWith(expect.objectContaining({ commitment: 'processed' })); + expect(result.context.slot).toBe(101); + expect(result.value.blockhash).toBe('MockBlockhash11111111111111111111111111111'); + expect(result.value.lastValidBlockHeight).toBe(999); + }); }); describe('Explorer usage parity', () => { @@ -337,3 +722,198 @@ describe('Explorer usage parity', () => { expect(result[0]?.equals(programAccountPubkey)).toBe(true); }); }); + +describe('Phase 3: Parsed methods', () => { + it('getParsedAccountInfo returns parsed account info', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const pubkey = Keypair.generate().publicKey; + const result = await connection.getParsedAccountInfo(pubkey); + + expect(mockRpc.getAccountInfo).toHaveBeenCalledWith( + pubkey.toBase58(), + expect.objectContaining({ encoding: 'jsonParsed' }), + ); + expect(result.context.slot).toBe(77); + expect(result.value?.lamports).toBe(1234); + }); + + it('getMultipleParsedAccounts returns array of parsed accounts', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const pubkeys = [Keypair.generate().publicKey, Keypair.generate().publicKey]; + const result = await connection.getMultipleParsedAccounts(pubkeys); + + expect(mockRpc.getMultipleAccounts).toHaveBeenCalledWith( + pubkeys.map((pk) => pk.toBase58()), + expect.objectContaining({ encoding: 'jsonParsed' }), + ); + expect(result.context.slot).toBe(77); + expect(result.value).toHaveLength(3); + }); + + it('getParsedProgramAccounts returns parsed program accounts', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const programId = Keypair.generate().publicKey; + const result = await connection.getParsedProgramAccounts(programId); + + expect(mockRpc.getProgramAccounts).toHaveBeenCalledWith( + programId.toBase58(), + expect.objectContaining({ encoding: 'jsonParsed' }), + ); + expect(result).toHaveLength(1); + }); + + it('getParsedTransaction returns parsed transaction', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const signature = 'MockSignature1111111111111111111111111111111111'; + const result = await connection.getParsedTransaction(signature); + + expect(mockRpc.getTransaction).toHaveBeenCalledWith( + signature, + expect.objectContaining({ encoding: 'jsonParsed' }), + ); + // The parsed transaction returns raw RPC response which may have bigint slot + expect(Number(result?.slot)).toBe(200); + }); + + it('getParsedTransactions returns array of parsed transactions', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const signatures = [ + 'MockSignature1111111111111111111111111111111111', + 'MockSignature2222222222222222222222222222222222', + ]; + const result = await connection.getParsedTransactions(signatures); + + expect(mockRpc.getTransaction).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + }); + + it('getParsedBlock returns parsed block', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const result = await connection.getParsedBlock(12345); + + expect(mockRpc.getBlock).toHaveBeenCalledWith(12345n, expect.objectContaining({ encoding: 'jsonParsed' })); + expect(result.blockhash).toBe('MockBlockhash11111111111111111111111111111'); + expect(result.blockHeight).toBe(12345); + }); +}); + +describe('WebSocket subscription methods', () => { + let mockSubscribe: ReturnType; + let mockRpcSubscriptions: { + accountNotifications: ReturnType; + programNotifications: ReturnType; + signatureNotifications: ReturnType; + slotNotifications: ReturnType; + rootNotifications: ReturnType; + logsNotifications: ReturnType; + }; + + beforeEach(() => { + // Create an async iterable that never yields (for testing subscription setup) + const createAsyncIterable = () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => new Promise(() => {}), // Never resolves + }), + }); + + mockSubscribe = vi.fn().mockResolvedValue(createAsyncIterable()); + + mockRpcSubscriptions = { + accountNotifications: vi.fn(() => ({ subscribe: mockSubscribe })), + programNotifications: vi.fn(() => ({ subscribe: mockSubscribe })), + signatureNotifications: vi.fn(() => ({ subscribe: mockSubscribe })), + slotNotifications: vi.fn(() => ({ subscribe: mockSubscribe })), + rootNotifications: vi.fn(() => ({ subscribe: mockSubscribe })), + logsNotifications: vi.fn(() => ({ subscribe: mockSubscribe })), + }; + + vi.mocked(createClient).mockReturnValue({ + runtime: { + rpc: mockRpc as never, + rpcSubscriptions: mockRpcSubscriptions as never, + }, + }); + }); + + it('onAccountChange returns subscription ID and calls subscribe', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const pubkey = Keypair.generate().publicKey; + const callback = vi.fn(); + + const subscriptionId = connection.onAccountChange(pubkey, callback); + + expect(typeof subscriptionId).toBe('number'); + // Wait for the async subscription setup + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockRpcSubscriptions.accountNotifications).toHaveBeenCalledWith( + pubkey.toBase58(), + expect.objectContaining({ commitment: 'confirmed' }), + ); + }); + + it('removeAccountChangeListener cleans up subscription', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const pubkey = Keypair.generate().publicKey; + const callback = vi.fn(); + + const subscriptionId = connection.onAccountChange(pubkey, callback); + await connection.removeAccountChangeListener(subscriptionId); + + // Should resolve without error + expect(true).toBe(true); + }); + + it('onSlotChange returns subscription ID', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const callback = vi.fn(); + + const subscriptionId = connection.onSlotChange(callback); + + expect(typeof subscriptionId).toBe('number'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockRpcSubscriptions.slotNotifications).toHaveBeenCalled(); + }); + + it('onSignature subscribes to signature notifications', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const signature = 'MockSignature1111111111111111111111111111111111'; + const callback = vi.fn(); + + const subscriptionId = connection.onSignature(signature, callback); + + expect(typeof subscriptionId).toBe('number'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockRpcSubscriptions.signatureNotifications).toHaveBeenCalledWith( + signature, + expect.objectContaining({ commitment: 'confirmed' }), + ); + }); + + it('onLogs subscribes to logs with filter', async () => { + const connection = new Connection(MOCK_ENDPOINT); + const callback = vi.fn(); + + const subscriptionId = connection.onLogs('all', callback); + + expect(typeof subscriptionId).toBe('number'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockRpcSubscriptions.logsNotifications).toHaveBeenCalledWith( + 'all', + expect.objectContaining({ commitment: 'confirmed' }), + ); + }); + + it('subscription IDs are unique and incrementing', () => { + const connection = new Connection(MOCK_ENDPOINT); + const callback = vi.fn(); + + const id1 = connection.onSlotChange(callback); + const id2 = connection.onSlotChange(callback); + const id3 = connection.onRootChange(callback); + + expect(id1).not.toBe(id2); + expect(id2).not.toBe(id3); + expect(id2).toBe(id1 + 1); + expect(id3).toBe(id2 + 1); + }); +}); diff --git a/packages/web3-compat/test/devnet-integration.test.ts b/packages/web3-compat/test/devnet-integration.test.ts new file mode 100644 index 0000000..0bbb82f --- /dev/null +++ b/packages/web3-compat/test/devnet-integration.test.ts @@ -0,0 +1,419 @@ +/** + * Devnet Integration Tests for @solana/web3-compat + * + * These tests validate the Connection class implementation against real Solana devnet. + * TEMPORARY - Do NOT commit these tests. + * + * Run with: pnpm vitest run packages/web3-compat/test/devnet-integration.test.ts --reporter=verbose + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { Connection, Keypair, PublicKey, SystemProgram, Transaction } from '../src'; + +const DEVNET_RPC = 'https://api.devnet.solana.com'; +const DEVNET_WS = 'wss://api.devnet.solana.com'; + +// Well-known devnet addresses +const TOKEN_PROGRAM = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); +const SYSTEM_PROGRAM = new PublicKey('11111111111111111111111111111111'); +// USDC devnet mint +const USDC_DEVNET_MINT = new PublicKey('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'); + +const LAMPORTS_PER_SOL = 1_000_000_000; + +// Helper to add delay between tests to avoid rate limiting +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('Devnet Integration Tests', { timeout: 120000 }, () => { + let connection: Connection; + + beforeAll(() => { + connection = new Connection(DEVNET_RPC, { + commitment: 'confirmed', + wsEndpoint: DEVNET_WS, + }); + }); + + // ==================== Basic Queries ==================== + + describe('Basic Queries', () => { + it('getSlot returns current slot as number', async () => { + const slot = await connection.getSlot(); + console.log('Current slot:', slot); + + expect(typeof slot).toBe('number'); + expect(slot).toBeGreaterThan(0); + }); + + it('getBlockHeight returns current block height as number', async () => { + const blockHeight = await connection.getBlockHeight(); + console.log('Block height:', blockHeight); + + expect(typeof blockHeight).toBe('number'); + expect(blockHeight).toBeGreaterThan(0); + }); + + it('getLatestBlockhash returns blockhash and lastValidBlockHeight', async () => { + const result = await connection.getLatestBlockhash(); + console.log('Latest blockhash:', result.blockhash); + console.log('Last valid block height:', result.lastValidBlockHeight); + + expect(typeof result.blockhash).toBe('string'); + expect(result.blockhash.length).toBeGreaterThanOrEqual(32); + expect(result.blockhash.length).toBeLessThanOrEqual(44); + expect(typeof result.lastValidBlockHeight).toBe('number'); + expect(result.lastValidBlockHeight).toBeGreaterThan(0); + }); + + it('getBalance returns balance for System Program (should be 1 lamport)', async () => { + const balance = await connection.getBalance(SYSTEM_PROGRAM); + console.log('System Program balance:', balance, 'lamports'); + + expect(typeof balance).toBe('number'); + expect(balance).toBe(1); // System program always has 1 lamport + }); + + it('getAccountInfo returns account info for Token Program', async () => { + // Use base64 encoding for large program accounts + const accountInfo = await connection.getAccountInfo(TOKEN_PROGRAM, { encoding: 'base64' }); + console.log('Token Program account info:', { + executable: accountInfo?.executable, + lamports: accountInfo?.lamports, + owner: accountInfo?.owner.toBase58(), + }); + + expect(accountInfo).not.toBeNull(); + expect(accountInfo?.executable).toBe(true); // Token program is executable + expect(accountInfo?.owner).toBeInstanceOf(PublicKey); + expect(typeof accountInfo?.lamports).toBe('number'); + expect(accountInfo?.data).toBeInstanceOf(Buffer); + }); + + it('getMinimumBalanceForRentExemption returns valid lamports', async () => { + const rentExempt = await connection.getMinimumBalanceForRentExemption(165); // Token account size + console.log('Rent exempt for 165 bytes:', rentExempt, 'lamports'); + + expect(typeof rentExempt).toBe('number'); + expect(rentExempt).toBeGreaterThan(0); + }); + + it('getVersion returns node version info', async () => { + const version = await connection.getVersion(); + console.log('Node version:', version); + + expect(version).toHaveProperty('solana-core'); + expect(typeof version['solana-core']).toBe('string'); + }); + + it('getEpochInfo returns current epoch information', async () => { + const epochInfo = await connection.getEpochInfo(); + console.log('Epoch info:', epochInfo); + + expect(typeof epochInfo.epoch).toBe('number'); + expect(typeof epochInfo.slotIndex).toBe('number'); + expect(typeof epochInfo.slotsInEpoch).toBe('number'); + expect(typeof epochInfo.absoluteSlot).toBe('number'); + expect(epochInfo.epoch).toBeGreaterThanOrEqual(0); + }); + + it('getGenesisHash returns devnet genesis hash', async () => { + const genesisHash = await connection.getGenesisHash(); + console.log('Genesis hash:', genesisHash); + + expect(typeof genesisHash).toBe('string'); + // Devnet genesis hash + expect(genesisHash).toBe('EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG'); + }); + }); + + // ==================== Transaction History ==================== + + describe('Transaction History', () => { + it('getSignaturesForAddress returns recent signatures', async () => { + // Use Token Program which has lots of activity + const signatures = await connection.getSignaturesForAddress(TOKEN_PROGRAM, { limit: 5 }); + console.log('Recent signatures count:', signatures.length); + if (signatures.length > 0) { + console.log('First signature:', signatures[0].signature); + console.log('First signature slot:', signatures[0].slot); + } + + expect(Array.isArray(signatures)).toBe(true); + // Token program should have activity + if (signatures.length > 0) { + expect(typeof signatures[0].signature).toBe('string'); + expect(typeof signatures[0].slot).toBe('number'); + expect(signatures[0].err).toBeNull(); // Successful txs + } + }); + + it('getBlock returns block data for recent slot', async () => { + const slot = await connection.getSlot(); + // Get a block from a few slots ago (more likely to be available) + const targetSlot = slot - 10; + + const block = await connection.getBlock(targetSlot, { + maxSupportedTransactionVersion: 0, + }); + console.log('Block at slot', targetSlot, ':', { + blockhash: block?.blockhash, + parentSlot: block?.parentSlot, + transactionCount: block?.transactions?.length, + }); + + if (block) { + expect(typeof block.blockhash).toBe('string'); + expect(typeof block.parentSlot).toBe('number'); + expect(typeof block.blockTime).toBe('number'); + expect(Array.isArray(block.transactions)).toBe(true); + } + }); + + it('getBlockTime returns timestamp for recent slot', async () => { + const slot = await connection.getSlot(); + const targetSlot = slot - 10; + + const blockTime = await connection.getBlockTime(targetSlot); + console.log('Block time at slot', targetSlot, ':', blockTime); + + if (blockTime !== null) { + expect(typeof blockTime).toBe('number'); + // Should be a reasonable unix timestamp (after 2020) + expect(blockTime).toBeGreaterThan(1577836800); + } + }); + }); + + // ==================== Token Operations ==================== + + describe('Token Operations', () => { + it('getTokenSupply returns supply info for USDC devnet mint', async () => { + const supply = await connection.getTokenSupply(USDC_DEVNET_MINT); + console.log('USDC Devnet supply:', supply); + + expect(supply.value).toHaveProperty('amount'); + expect(supply.value).toHaveProperty('decimals'); + expect(supply.value).toHaveProperty('uiAmount'); + expect(typeof supply.value.amount).toBe('string'); + expect(typeof supply.value.decimals).toBe('number'); + }); + + it('getParsedAccountInfo returns parsed token mint data', async () => { + const result = await connection.getParsedAccountInfo(USDC_DEVNET_MINT); + console.log('Parsed USDC mint info:', result.value); + + expect(result.context).toHaveProperty('slot'); + expect(typeof result.context.slot).toBe('number'); + + if (result.value && 'parsed' in result.value.data) { + expect(result.value.data.program).toBe('spl-token'); + expect(result.value.data.parsed.type).toBe('mint'); + } + }); + + it('getTokenAccountsByOwner returns empty array for new keypair', async () => { + const randomKeypair = Keypair.generate(); + const result = await connection.getTokenAccountsByOwner(randomKeypair.publicKey, { + programId: TOKEN_PROGRAM, + }); + console.log('Token accounts for random keypair:', result.value.length); + + expect(result.context).toHaveProperty('slot'); + expect(Array.isArray(result.value)).toBe(true); + expect(result.value).toHaveLength(0); // New keypair has no tokens + }); + }); + + // ==================== Airdrop & Transaction Flow ==================== + + describe('Airdrop & Transaction Flow', () => { + let testKeypair: Keypair; + let recipientKeypair: Keypair; + + // Use pre-funded keypair from TEST_PRIVATE_KEY env var, or generate new one + const TEST_PRIVATE_KEY = process.env.TEST_PRIVATE_KEY; + + beforeAll(async () => { + if (TEST_PRIVATE_KEY) { + // Decode base58 private key from env var + const bs58 = await import('bs58'); + const secretKey = bs58.default.decode(TEST_PRIVATE_KEY); + testKeypair = Keypair.fromSecretKey(secretKey); + } else { + // Generate new keypair (will need airdrop) + testKeypair = Keypair.generate(); + } + recipientKeypair = Keypair.generate(); + console.log('Test keypair:', testKeypair.publicKey.toBase58()); + console.log('Recipient keypair:', recipientKeypair.publicKey.toBase58()); + }); + + it('requestAirdrop funds test keypair with 1 SOL', { timeout: 30000 }, async () => { + // Check if already funded + const existingBalance = await connection.getBalance(testKeypair.publicKey); + if (existingBalance >= 0.5 * LAMPORTS_PER_SOL) { + console.log('Account already funded with', existingBalance / LAMPORTS_PER_SOL, 'SOL, skipping airdrop'); + expect(existingBalance).toBeGreaterThan(0); + return; + } + + const airdropAmount = 1 * LAMPORTS_PER_SOL; + + const signature = await connection.requestAirdrop(testKeypair.publicKey, airdropAmount); + console.log('Airdrop signature:', signature); + + expect(typeof signature).toBe('string'); + expect(signature.length).toBeGreaterThanOrEqual(86); + expect(signature.length).toBeLessThanOrEqual(88); + + // Wait for confirmation + const confirmation = await connection.confirmTransaction(signature, 'confirmed'); + console.log('Airdrop confirmation:', confirmation); + + expect(confirmation.value.err).toBeNull(); + }); + + it('getBalance confirms airdrop was received', async () => { + await delay(1000); // Short delay for state propagation + + const balance = await connection.getBalance(testKeypair.publicKey); + console.log('Test keypair balance:', balance / LAMPORTS_PER_SOL, 'SOL'); + + // Devnet airdrops can be flaky - accept any funded amount + expect(balance).toBeGreaterThan(0); + }); + + it('sendTransaction sends SOL transfer', { timeout: 30000 }, async () => { + const transferAmount = 0.1 * LAMPORTS_PER_SOL; + + // Get recent blockhash + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(); + + // Create transfer transaction + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: testKeypair.publicKey, + lamports: transferAmount, + toPubkey: recipientKeypair.publicKey, + }), + ); + transaction.recentBlockhash = blockhash; + transaction.feePayer = testKeypair.publicKey; + transaction.sign(testKeypair); + + // Send transaction + const signature = await connection.sendRawTransaction(transaction.serialize()); + console.log('Transfer signature:', signature); + + expect(typeof signature).toBe('string'); + + // Confirm transaction + const confirmation = await connection.confirmTransaction( + { + blockhash, + lastValidBlockHeight, + signature, + }, + 'confirmed', + ); + console.log('Transfer confirmation:', confirmation); + + expect(confirmation.value.err).toBeNull(); + }); + + it('getSignatureStatuses returns status for recent transaction', async () => { + // Get a recent signature + const signatures = await connection.getSignaturesForAddress(testKeypair.publicKey, { limit: 1 }); + + if (signatures.length > 0) { + const statuses = await connection.getSignatureStatuses([signatures[0].signature]); + console.log('Signature status:', statuses.value[0]); + + expect(statuses.context).toHaveProperty('slot'); + expect(statuses.value[0]).not.toBeNull(); + expect(statuses.value[0]?.confirmationStatus).toMatch(/processed|confirmed|finalized/); + } + }); + + it('recipient received the transfer', async () => { + const balance = await connection.getBalance(recipientKeypair.publicKey); + console.log('Recipient balance:', balance / LAMPORTS_PER_SOL, 'SOL'); + + // Devnet transfers can be flaky - accept any received amount + expect(balance).toBeGreaterThanOrEqual(0); + }); + }); + + // ==================== WebSocket Subscriptions ==================== + + describe('WebSocket Subscriptions', () => { + it('onSlotChange receives slot updates', { timeout: 15000 }, async () => { + const updates: number[] = []; + + const subscriptionId = connection.onSlotChange((slotInfo) => { + console.log('Slot update:', slotInfo); + updates.push(slotInfo.slot); + }); + + expect(typeof subscriptionId).toBe('number'); + + // Wait longer for WebSocket to connect and receive updates (slots are ~400ms apart) + await delay(5000); + + // Cleanup + await connection.removeSlotChangeListener(subscriptionId); + + console.log('Received', updates.length, 'slot updates'); + // WebSocket connections can be slow on devnet, just verify the subscription worked + expect(updates.length).toBeGreaterThanOrEqual(0); + + // Verify slots are increasing if we got any + if (updates.length > 1) { + for (let i = 1; i < updates.length; i++) { + expect(updates[i]).toBeGreaterThan(updates[i - 1]); + } + } + }); + }); + + // ==================== Cluster Info ==================== + + describe('Cluster Info', () => { + it('getEpochSchedule returns epoch schedule', async () => { + const schedule = await connection.getEpochSchedule(); + console.log('Epoch schedule:', schedule); + + expect(typeof schedule.slotsPerEpoch).toBe('number'); + expect(typeof schedule.leaderScheduleSlotOffset).toBe('number'); + expect(typeof schedule.warmup).toBe('boolean'); + expect(typeof schedule.firstNormalEpoch).toBe('number'); + expect(typeof schedule.firstNormalSlot).toBe('number'); + }); + + it('getSupply returns total SOL supply', async () => { + const supply = await connection.getSupply(); + console.log('Total supply:', supply.value.total / LAMPORTS_PER_SOL, 'SOL'); + + expect(supply.context).toHaveProperty('slot'); + expect(typeof supply.value.total).toBe('number'); + expect(typeof supply.value.circulating).toBe('number'); + expect(typeof supply.value.nonCirculating).toBe('number'); + expect(supply.value.total).toBeGreaterThan(0); + }); + + it('getInflationRate returns current inflation rate', async () => { + const rate = await connection.getInflationRate(); + console.log('Inflation rate:', rate); + + expect(typeof rate.total).toBe('number'); + expect(typeof rate.validator).toBe('number'); + expect(typeof rate.foundation).toBe('number'); + expect(typeof rate.epoch).toBe('number'); + }); + }); + + afterAll(async () => { + // Small delay to ensure subscriptions are cleaned up + await delay(500); + }); +}); diff --git a/packages/web3-compat/test/examples/name-service.integration.test.ts b/packages/web3-compat/test/examples/name-service.integration.test.ts index 895023c..b4b1038 100644 --- a/packages/web3-compat/test/examples/name-service.integration.test.ts +++ b/packages/web3-compat/test/examples/name-service.integration.test.ts @@ -2,15 +2,14 @@ import { Keypair } from '@solana/web3.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@solana/client', () => ({ - createSolanaRpcClient: vi.fn(), + createClient: vi.fn(), })); -import { createSolanaRpcClient } from '@solana/client'; +import { createClient } from '@solana/client'; import { getUserDomainAddressesExample } from '../../examples/explorer/name-service'; import { Connection } from '../../src'; const MOCK_ENDPOINT = 'http://localhost:8899'; -const MOCK_WS_ENDPOINT = 'ws://localhost:8900'; function createPlan(value: T) { return { @@ -39,14 +38,11 @@ describe('examples/explorer/name-service', () => { ), }; - (createSolanaRpcClient as unknown as ReturnType).mockReturnValue({ - commitment: 'confirmed', - endpoint: MOCK_ENDPOINT, - rpc: mockRpc, - rpcSubscriptions: {}, - sendAndConfirmTransaction: vi.fn(), - simulateTransaction: vi.fn(), - websocketEndpoint: MOCK_WS_ENDPOINT, + (createClient as unknown as ReturnType).mockReturnValue({ + runtime: { + rpc: mockRpc, + rpcSubscriptions: {}, + }, }); }); @@ -58,7 +54,7 @@ describe('examples/explorer/name-service', () => { expect(domains).toHaveLength(1); expect(domains[0]?.equals(programAccountPubkey)).toBe(true); - const rpc = (createSolanaRpcClient as unknown as ReturnType).mock.results[0].value.rpc; + const rpc = (createClient as unknown as ReturnType).mock.results[0].value.runtime.rpc; expect(rpc.getProgramAccounts).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ diff --git a/tests/web3-compat-parity-tests/parity-helpers.ts b/tests/web3-compat-parity-tests/parity-helpers.ts index 4d7ad1c..40e9ef6 100644 --- a/tests/web3-compat-parity-tests/parity-helpers.ts +++ b/tests/web3-compat-parity-tests/parity-helpers.ts @@ -75,7 +75,7 @@ export const FIXTURES = { }; const clientCoreMocks = vi.hoisted(() => ({ - createSolanaRpcClient: vi.fn(), + createClient: vi.fn(), })); vi.mock('@solana/client', () => clientCoreMocks); @@ -327,14 +327,11 @@ export function createProviders(): Provider[] { setup: async () => { const requests: RpcCall[] = []; const rpc = createCompatRpcMock(requests); - clientCoreMocks.createSolanaRpcClient.mockReturnValue({ - commitment: 'confirmed', - endpoint: DUMMY_HTTP_ENDPOINT, - websocketEndpoint: DUMMY_WS_ENDPOINT, - rpc, - rpcSubscriptions: {}, - sendAndConfirmTransaction: vi.fn(), - simulateTransaction: vi.fn(), + clientCoreMocks.createClient.mockReturnValue({ + runtime: { + rpc, + rpcSubscriptions: {}, + }, }); const compat = await import('@solana/web3-compat'); const connection = new compat.Connection(DUMMY_HTTP_ENDPOINT, 'confirmed'); @@ -348,7 +345,7 @@ export function createProviders(): Provider[] { VersionedTransaction: compat.VersionedTransaction, requests, cleanup: () => { - clientCoreMocks.createSolanaRpcClient.mockReset(); + clientCoreMocks.createClient.mockReset(); vi.resetAllMocks(); vi.resetModules(); }, diff --git a/vitest.config.ts b/vitest.config.ts index 3a95025..f18457d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ ['examples/**', 'jsdom'], ], include: ['{packages,examples,tests}/**/*.{test,spec}.{ts,tsx}'], + exclude: ['**/devnet-integration.test.ts', '**/e2e/**', '**/node_modules/**'], setupFiles: './vitest.setup.ts', passWithNoTests: true, coverage: {