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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- SDK: Viem adapters (`fromViemClient`, `viemWallet`) now accept wagmi/RainbowKit clients including OP Stack chains
- SDK: **Breaking**: `CCIPRequest` now includes optional `metadata?: APICCIPRequestMetadata` field
- API fields (`status`, `receiptTransactionHash`, `deliveryTime`, etc.) moved under `metadata`
- Migration: Change `request.status` to `request.metadata?.status`
Expand Down
2 changes: 1 addition & 1 deletion ccip-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Format } from './commands/index.ts'
util.inspect.defaultOptions.depth = 6 // print down to tokenAmounts in requests
// generate:nofail
// `const VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'`
const VERSION = '0.95.0-90b6f43'
const VERSION = '0.95.0-f080759'
// generate:end

const globalOpts = {
Expand Down
61 changes: 61 additions & 0 deletions ccip-sdk/src/evm/viem/client-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ describe('fromViemClient', () => {
)
})

it('should throw if chain.id is not defined', async () => {
const mockClient = {
chain: { name: 'Ethereum' }, // Missing id
request: async () => '0x1',
}

await assert.rejects(
() => fromViemClient(mockClient as never),
(err: Error) => {
assert.ok(err instanceof CCIPViemAdapterError)
assert.ok(err.message.includes('chain'))
return true
},
)
})

it('should work with http transport', async () => {
const mockClient = {
chain: { id: 1, name: 'Ethereum' },
Expand Down Expand Up @@ -158,3 +174,48 @@ describe('ViemTransportProvider', () => {
assert.ok(errorResult.error.message.includes('RPC error'))
})
})

describe('fromViemClient - Structural Type Acceptance', () => {
it('should accept minimal structural client (RainbowKit pattern)', async () => {
const minimalClient = {
chain: { id: 1, name: 'Ethereum' },
request: async () => '0x1',
}

// Should not throw type-related errors
try {
await fromViemClient(minimalClient as never)
} catch (err) {
// May fail at provider level, but type acceptance should work
assert.ok(!(err instanceof CCIPViemAdapterError && err.message.includes('type')))
}
})

it('should accept client with readonly chain (wagmi freezes configs)', async () => {
const frozenClient = {
chain: Object.freeze({ id: 1, name: 'Ethereum' }),
request: async () => '0x1',
}

try {
await fromViemClient(frozenClient as never)
} catch (err) {
assert.ok(!(err instanceof CCIPViemAdapterError))
}
})

it('should reject client without chain', async () => {
const noChainClient = { request: async () => '0x1' }

await assert.rejects(() => fromViemClient(noChainClient as never), /must have a chain defined/)
})

it('should reject client with null chain', async () => {
const nullChainClient = { chain: null, request: async () => '0x1' }

await assert.rejects(
() => fromViemClient(nullChainClient as never),
/must have a chain defined/,
)
})
})
49 changes: 43 additions & 6 deletions ccip-sdk/src/evm/viem/client-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Chain, PublicClient, Transport } from 'viem'
import type { ChainContext } from '../../chain.ts'
import { CCIPViemAdapterError } from '../../errors/index.ts'
import { EVMChain } from '../index.ts'
import type { ViemClientLike } from './types.ts'

/**
* Custom ethers provider that forwards RPC calls through viem's transport.
Expand Down Expand Up @@ -66,13 +67,19 @@ export class ViemTransportProvider extends JsonRpcApiProvider {
/**
* Create EVMChain from a viem PublicClient.
*
* Supports ALL viem transport types including:
* Accepts any viem-compatible client including:
* - Direct viem createPublicClient()
* - Wagmi's usePublicClient() / getPublicClient()
* - RainbowKit's getDefaultConfig() clients
* - Any object with chain.id, chain.name, and request()
*
* Supports ALL viem transport types and chain configurations including:
* - http() - Standard HTTP transport
* - webSocket() - WebSocket transport
* - custom() - Injected providers (MetaMask, WalletConnect, etc.)
* - fallback() - Fallback transport with multiple providers
*
* @param client - viem PublicClient instance with chain defined
* @param client - Any viem-compatible client with chain defined
* @param ctx - Optional chain context (logger, etc.)
* @returns EVMChain instance
*
Expand Down Expand Up @@ -104,20 +111,50 @@ export class ViemTransportProvider extends JsonRpcApiProvider {
*
* const chain = await fromViemClient(publicClient)
* ```
*
* @example Wagmi integration
* ```typescript
* import { usePublicClient } from 'wagmi'
* import { fromViemClient } from '@chainlink/ccip-sdk/viem'
*
* const publicClient = usePublicClient()
* if (publicClient) {
* const chain = await fromViemClient(publicClient)
* }
* ```
*
* @example RainbowKit + wagmi (works with OP Stack chains)
* ```typescript
* import { getDefaultConfig } from '@rainbow-me/rainbowkit'
* import { getPublicClient } from '@wagmi/core'
* import { sepolia, baseSepolia } from 'wagmi/chains'
* import { fromViemClient } from '@chainlink/ccip-sdk/viem'
*
* const config = getDefaultConfig({
* chains: [sepolia, baseSepolia], // OP Stack chains work!
* // ...
* })
*
* const client = getPublicClient(config)
* if (client) {
* const chain = await fromViemClient(client) // No type cast needed!
* }
* ```
*/
export async function fromViemClient(
client: PublicClient<Transport, Chain>,
client: ViemClientLike,
ctx?: ChainContext,
): Promise<EVMChain> {
// Validate chain is defined
if (!(client as Partial<typeof client>).chain) {
// Validate chain is defined (runtime check)
if (!client.chain?.id) {
throw new CCIPViemAdapterError('PublicClient must have a chain defined', {
recovery: 'Pass a chain to createPublicClient: createPublicClient({ chain: mainnet, ... })',
})
}

// Use custom provider that wraps viem transport (works for ALL transport types)
const provider = new ViemTransportProvider(client)
// Cast is safe - we've validated the required properties
const provider = new ViemTransportProvider(client as PublicClient<Transport, Chain>)

// Use existing EVMChain.fromProvider
return EVMChain.fromProvider(provider, ctx)
Expand Down
2 changes: 1 addition & 1 deletion ccip-sdk/src/evm/viem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@

export { ViemTransportProvider, fromViemClient } from './client-adapter.ts'
export { viemWallet } from './wallet-adapter.ts'
export type { ViemPublicClient, ViemWalletClient } from './types.ts'
export type { ViemClientLike, ViemWalletClientLike } from './types.ts'
64 changes: 55 additions & 9 deletions ccip-sdk/src/evm/viem/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,60 @@
import type { Account, Chain, PublicClient, Transport, WalletClient } from 'viem'

/**
* Viem PublicClient with required chain property.
* Chain is required to determine the network for EVMChain.
* Minimal structural interface for viem-compatible public clients.
*
* This interface uses structural typing to accept any client with the required
* properties, including wagmi/RainbowKit's complex computed types that fail
* with viem's strict generic types.
*
* ## Why `unknown` for methods?
*
* TypeScript function parameters are **contravariant**. When we define:
* ```ts
* request: (args: { method: string }) => Promise<unknown>
* ```
* And viem defines:
* ```ts
* request: (args: { method: "eth_blockNumber" } | { method: "eth_call"; params: [...] }) => Promise<...>
* ```
* Our broader `string` type is NOT assignable to their specific union, causing errors.
*
* By using `unknown`, we tell TypeScript: "don't check this function's signature".
* Type safety is preserved at the **call site** (viem enforces it), not at the
* SDK boundary (where we just need to pass the client through).
*
* ## Why this is needed
*
* OP Stack chains (Base, Optimism) have `type: "deposit"` transactions that
* L1 chains don't have. When wagmi creates `PublicClient<Transport, sepolia | baseSepolia>`,
* the return types of methods like `getBlock()` become incompatible unions.
* This structural interface sidesteps that entirely.
*/
export type ViemPublicClient = PublicClient<Transport, Chain>
export interface ViemClientLike {
/** Chain configuration - required for network identification */
readonly chain: {
readonly id: number
readonly name: string
} | null
/**
* EIP-1193 request function.
* Typed as `unknown` to avoid contravariance issues with viem's strict method unions.
* Runtime: this is viem's `client.request()` method.
*/
request: unknown
}

/**
* Viem WalletClient with required account and chain properties.
* Account is required to get the signer address.
* Chain is required to determine the network.
* Minimal structural interface for viem-compatible wallet clients.
* Extends ViemClientLike with account information for signing operations.
*/
export type ViemWalletClient = WalletClient<Transport, Chain, Account>
export interface ViemWalletClientLike extends ViemClientLike {
/**
* Connected account - required for signing operations.
* Address typed as `unknown` because viem uses `string | Addressable`.
*/
readonly account:
| {
readonly address: unknown
readonly type: string
}
| undefined
}
45 changes: 40 additions & 5 deletions ccip-sdk/src/evm/viem/wallet-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import type { Account, Chain, PublicClient, Transport, WalletClient } from 'viem'

import { ViemTransportProvider } from './client-adapter.ts'
import type { ViemWalletClientLike } from './types.ts'
import { CCIPViemAdapterError } from '../../errors/index.ts'

/**
Expand Down Expand Up @@ -160,6 +161,12 @@ class ViemWalletAdapter extends AbstractSigner {
/**
* Convert viem WalletClient to ethers-compatible Signer.
*
* Accepts any viem-compatible wallet client including:
* - Direct viem createWalletClient()
* - Wagmi's useWalletClient() / getWalletClient()
* - RainbowKit's wallet clients
* - Any object with account, chain, and signing methods
*
* Supports both:
* - Local accounts (privateKeyToAccount, mnemonicToAccount)
* - JSON-RPC accounts (browser wallets like MetaMask)
Expand Down Expand Up @@ -210,24 +217,52 @@ class ViemWalletAdapter extends AbstractSigner {
* // Works with injected providers!
* const signer = viemWallet(walletClient)
* ```
*
* @example Wagmi integration
* ```typescript
* import { useWalletClient } from 'wagmi'
* import { viemWallet } from '@chainlink/ccip-sdk/viem'
*
* const { data: walletClient } = useWalletClient()
* if (walletClient) {
* const signer = viemWallet(walletClient)
* }
* ```
*
* @example RainbowKit + wagmi (works with OP Stack chains)
* ```typescript
* import { getWalletClient } from '@wagmi/core'
* import { viemWallet } from '@chainlink/ccip-sdk/viem'
*
* const walletClient = await getWalletClient(config)
* if (walletClient) {
* const signer = viemWallet(walletClient) // No type cast needed!
* }
* ```
*/
export function viemWallet(client: WalletClient<Transport, Chain, Account>): AbstractSigner {
// Validate account is defined
if (!(client.account as Account | undefined)) {
export function viemWallet(client: ViemWalletClientLike): AbstractSigner {
// Validate account is defined (runtime check)
if (!client.account?.address) {
throw new CCIPViemAdapterError('WalletClient must have an account defined', {
recovery: 'Pass an account to createWalletClient or use .extend(walletActions)',
})
}

if (!(client.chain as Chain | undefined)) {
// Validate chain is defined (runtime check)
if (!client.chain?.id) {
throw new CCIPViemAdapterError('WalletClient must have a chain defined', {
recovery: 'Pass a chain to createWalletClient: createWalletClient({ chain: mainnet, ... })',
})
}

// Create provider that wraps viem transport (works for ALL transport types including injected)
// Cast is safe - we've validated the required properties
const provider = new ViemTransportProvider(client as unknown as PublicClient<Transport, Chain>)

// Return adapter that delegates signing to viem
return new ViemWalletAdapter(client, provider)
// Cast is safe - we've validated required properties
return new ViemWalletAdapter(
client as unknown as WalletClient<Transport, Chain, Account>,
provider,
)
}
Loading
Loading