Minimal multi-protocol SDK support (#2529)
### Description - Create MultiProtocolProvider - Create ProviderType enum and TypedProvider - Add viem, solana, and ethers6 dev deps to SDK - Upgrade Typescript and Eslint across monorepo (required for Viem) - Break out metadata mgmt code into new ChainMetadataManager class - Implement basic MultiProtocolApp and MultiProtocolRouterApp - Create Evm and Sealevel router adapters - Refactor contract types into separate file to avoid circular dep ### Related issues https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/2503 ### Backward compatibility Yespull/2607/head
parent
465112db6f
commit
d901ca2c6f
@ -1,13 +1,5 @@ |
||||
{ |
||||
"rules": { |
||||
"@typescript-eslint/explicit-module-boundary-types": ["error"], |
||||
"@typescript-eslint/no-unused-vars": [ |
||||
"warn", // or "error" |
||||
{ |
||||
"argsIgnorePattern": "^_", |
||||
"varsIgnorePattern": "^_", |
||||
"caughtErrorsIgnorePattern": "^_" |
||||
} |
||||
], |
||||
"@typescript-eslint/explicit-module-boundary-types": ["error"] |
||||
} |
||||
} |
||||
|
@ -0,0 +1,19 @@ |
||||
import { expect } from 'chai'; |
||||
|
||||
import { Chains } from '../consts/chains'; |
||||
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider'; |
||||
|
||||
import { MultiProtocolApp } from './MultiProtocolApp'; |
||||
|
||||
class TestMultiProtocolApp extends MultiProtocolApp {} |
||||
|
||||
describe('MultiProtocolApp', () => { |
||||
describe('constructs', () => { |
||||
const multiProvider = new MultiProtocolProvider(); |
||||
it('creates an app class and gleans types from generic', async () => { |
||||
const app = new TestMultiProtocolApp(multiProvider); |
||||
expect(app).to.be.instanceOf(MultiProtocolApp); |
||||
expect(app.adapter(Chains.ethereum).protocol).to.eql(Chains.ethereum); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,98 @@ |
||||
import debug from 'debug'; |
||||
|
||||
import { ProtocolType, objMap, promiseObjAll } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ChainMetadata } from '../metadata/chainMetadataTypes'; |
||||
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider'; |
||||
import { ChainMap, ChainName } from '../types'; |
||||
import { MultiGeneric } from '../utils/MultiGeneric'; |
||||
|
||||
/** |
||||
* A minimal interface for an adapter that can be used with MultiProtocolApp |
||||
* The purpose of adapters is to implement protocol-specific functionality |
||||
* E.g. EvmRouterAdapter implements EVM-specific router functionality |
||||
* whereas SealevelRouterAdapter implements the same logic for Solana |
||||
*/ |
||||
export abstract class BaseAppAdapter<ContractAddrs = {}> { |
||||
public abstract readonly protocol: ProtocolType; |
||||
constructor( |
||||
public readonly multiProvider: MultiProtocolProvider<ContractAddrs>, |
||||
) {} |
||||
} |
||||
|
||||
export type AdapterClassType<ContractAddrs = {}, API = {}> = new ( |
||||
multiProvider: MultiProtocolProvider<ContractAddrs>, |
||||
) => API; |
||||
|
||||
export type AdapterProtocolMap<ContractAddrs = {}, API = {}> = Partial< |
||||
Record<ProtocolType, AdapterClassType<ContractAddrs, API>> |
||||
>; |
||||
|
||||
export class BaseEvmAdapter< |
||||
ContractAddrs = {}, |
||||
> extends BaseAppAdapter<ContractAddrs> { |
||||
public readonly protocol: ProtocolType = ProtocolType.Ethereum; |
||||
} |
||||
|
||||
export class BaseSealevelAdapter< |
||||
ContractAddrs = {}, |
||||
> extends BaseAppAdapter<ContractAddrs> { |
||||
public readonly protocol: ProtocolType = ProtocolType.Sealevel; |
||||
} |
||||
|
||||
/** |
||||
* A version of HyperlaneApp that can support different |
||||
* provider types across different protocol types. |
||||
* |
||||
* Intentionally minimal as it's meant to be extended. |
||||
* Extend this class as needed to add useful methods/properties. |
||||
* |
||||
* @typeParam ContractAddrs - A map of contract names to addresses |
||||
* @typeParam IAdapterApi - The type of the adapters for implementing the app's |
||||
* functionality across different protocols. |
||||
* |
||||
* @param multiProvider - A MultiProtocolProvider instance that MUST include the app's |
||||
* contract addresses in its chain metadata |
||||
* @param logger - A logger instance |
||||
*/ |
||||
export abstract class MultiProtocolApp< |
||||
ContractAddrs = {}, |
||||
IAdapterApi extends BaseAppAdapter = BaseAppAdapter, |
||||
> extends MultiGeneric<ChainMetadata<ContractAddrs>> { |
||||
// Subclasses should override this with more specific adapters
|
||||
public readonly protocolToAdapter: AdapterProtocolMap<any, BaseAppAdapter> = { |
||||
[ProtocolType.Ethereum]: BaseEvmAdapter, |
||||
[ProtocolType.Sealevel]: BaseSealevelAdapter, |
||||
}; |
||||
|
||||
constructor( |
||||
public readonly multiProvider: MultiProtocolProvider<ContractAddrs>, |
||||
public readonly logger = debug('hyperlane:MultiProtocolApp'), |
||||
) { |
||||
super(multiProvider.metadata); |
||||
} |
||||
|
||||
metadata(chain: ChainName): ChainMetadata<ContractAddrs> { |
||||
return this.get(chain); |
||||
} |
||||
|
||||
adapter(chain: ChainName): IAdapterApi { |
||||
const metadata = this.metadata(chain); |
||||
const Adapter = this.protocolToAdapter[ |
||||
metadata.protocol |
||||
] as AdapterClassType<ContractAddrs, IAdapterApi>; |
||||
if (!Adapter) |
||||
throw new Error(`No adapter for protocol ${metadata.protocol}`); |
||||
return new Adapter(this.multiProvider); |
||||
} |
||||
|
||||
adapters(): ChainMap<IAdapterApi> { |
||||
return this.map((chain, _) => this.adapter(chain)); |
||||
} |
||||
|
||||
adapterMap<Output>( |
||||
fn: (n: ChainName, a: IAdapterApi) => Promise<Output>, |
||||
): Promise<ChainMap<Output>> { |
||||
return promiseObjAll(objMap(this.adapters(), fn)); |
||||
} |
||||
} |
@ -0,0 +1,29 @@ |
||||
import type { ethers } from 'ethers'; |
||||
|
||||
import type { Address } from '@hyperlane-xyz/utils'; |
||||
|
||||
import type { ChainMap } from '../types'; |
||||
|
||||
export type AddressesMap = { |
||||
[key: string]: Address; |
||||
}; |
||||
|
||||
export type HyperlaneFactories = { |
||||
[key: string]: ethers.ContractFactory; |
||||
}; |
||||
|
||||
export type HyperlaneContracts<F extends HyperlaneFactories> = { |
||||
[P in keyof F]: Awaited<ReturnType<F[P]['deploy']>>; |
||||
}; |
||||
|
||||
export type HyperlaneContractsMap<F extends HyperlaneFactories> = ChainMap< |
||||
HyperlaneContracts<F> |
||||
>; |
||||
|
||||
export type HyperlaneAddresses<F extends HyperlaneFactories> = { |
||||
[P in keyof F]: Address; |
||||
}; |
||||
|
||||
export type HyperlaneAddressesMap<F extends HyperlaneFactories> = ChainMap< |
||||
HyperlaneAddresses<F> |
||||
>; |
@ -0,0 +1,289 @@ |
||||
import { Debugger, debug } from 'debug'; |
||||
|
||||
import { exclude, isNumeric } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { chainMetadata as defaultChainMetadata } from '../consts/chainMetadata'; |
||||
import { ChainMap, ChainName } from '../types'; |
||||
|
||||
import { |
||||
ChainMetadata, |
||||
getDomainId, |
||||
isValidChainMetadata, |
||||
} from './chainMetadataTypes'; |
||||
|
||||
export interface ChainMetadataManagerOptions { |
||||
loggerName?: string; |
||||
} |
||||
|
||||
/** |
||||
* A set of utilities to manage chain metadata |
||||
* Validates metadata on construction and provides useful methods |
||||
* for interacting with the data |
||||
*/ |
||||
export class ChainMetadataManager<MetaExt = {}> { |
||||
public readonly metadata: ChainMap<ChainMetadata<MetaExt>> = {}; |
||||
protected readonly logger: Debugger; |
||||
|
||||
/** |
||||
* Create a new ChainMetadataManager with the given chainMetadata, |
||||
* or the SDK's default metadata if not provided |
||||
*/ |
||||
constructor( |
||||
chainMetadata: ChainMap< |
||||
ChainMetadata<MetaExt> |
||||
> = defaultChainMetadata as ChainMap<ChainMetadata<MetaExt>>, |
||||
options: ChainMetadataManagerOptions = {}, |
||||
) { |
||||
Object.entries(chainMetadata).forEach(([key, cm]) => { |
||||
if (key !== cm.name) |
||||
throw new Error( |
||||
`Chain name mismatch: Key was ${key}, but name is ${cm.name}`, |
||||
); |
||||
this.addChain(cm); |
||||
}); |
||||
this.logger = debug(options?.loggerName || 'hyperlane:MetadataManager'); |
||||
} |
||||
|
||||
/** |
||||
* Add a chain to the MultiProvider |
||||
* @throws if chain's name or domain/chain ID collide |
||||
*/ |
||||
addChain(metadata: ChainMetadata<MetaExt>): void { |
||||
if (!isValidChainMetadata(metadata)) |
||||
throw new Error(`Invalid chain metadata for ${metadata.name}`); |
||||
// Ensure no two chains have overlapping names/domainIds/chainIds
|
||||
for (const chainMetadata of Object.values(this.metadata)) { |
||||
const { name, chainId, domainId } = chainMetadata; |
||||
if (name == metadata.name) |
||||
throw new Error(`Duplicate chain name: ${name}`); |
||||
// Chain and Domain Ids should be globally unique
|
||||
const idCollision = |
||||
chainId == metadata.chainId || |
||||
domainId == metadata.chainId || |
||||
(metadata.domainId && |
||||
(chainId == metadata.domainId || domainId === metadata.domainId)); |
||||
if (idCollision) |
||||
throw new Error( |
||||
`Chain/Domain id collision: ${name} and ${metadata.name}`, |
||||
); |
||||
} |
||||
this.metadata[metadata.name] = metadata; |
||||
} |
||||
|
||||
/** |
||||
* Get the metadata for a given chain name, chain id, or domain id |
||||
* @throws if chain's metadata has not been set |
||||
*/ |
||||
tryGetChainMetadata( |
||||
chainNameOrId: ChainName | number, |
||||
): ChainMetadata<MetaExt> | null { |
||||
let chainMetadata: ChainMetadata<MetaExt> | undefined; |
||||
if (isNumeric(chainNameOrId)) { |
||||
// Should be chain id or domain id
|
||||
chainMetadata = Object.values(this.metadata).find( |
||||
(m) => m.chainId == chainNameOrId || m.domainId == chainNameOrId, |
||||
); |
||||
} else if (typeof chainNameOrId === 'string') { |
||||
// Should be chain name
|
||||
chainMetadata = this.metadata[chainNameOrId]; |
||||
} |
||||
return chainMetadata || null; |
||||
} |
||||
|
||||
/** |
||||
* Get the metadata for a given chain name, chain id, or domain id |
||||
* @throws if chain's metadata has not been set |
||||
*/ |
||||
getChainMetadata(chainNameOrId: ChainName | number): ChainMetadata<MetaExt> { |
||||
const chainMetadata = this.tryGetChainMetadata(chainNameOrId); |
||||
if (!chainMetadata) |
||||
throw new Error(`No chain metadata set for ${chainNameOrId}`); |
||||
return chainMetadata; |
||||
} |
||||
|
||||
/** |
||||
* Get the name for a given chain name, chain id, or domain id |
||||
*/ |
||||
tryGetChainName(chainNameOrId: ChainName | number): string | null { |
||||
return this.tryGetChainMetadata(chainNameOrId)?.name ?? null; |
||||
} |
||||
|
||||
/** |
||||
* Get the name for a given chain name, chain id, or domain id |
||||
* @throws if chain's metadata has not been set |
||||
*/ |
||||
getChainName(chainNameOrId: ChainName | number): string { |
||||
return this.getChainMetadata(chainNameOrId).name; |
||||
} |
||||
|
||||
/** |
||||
* Get the names for all chains known to this MultiProvider |
||||
*/ |
||||
getKnownChainNames(): string[] { |
||||
return Object.keys(this.metadata); |
||||
} |
||||
|
||||
/** |
||||
* Get the id for a given chain name, chain id, or domain id |
||||
*/ |
||||
tryGetChainId(chainNameOrId: ChainName | number): number | null { |
||||
return this.tryGetChainMetadata(chainNameOrId)?.chainId ?? null; |
||||
} |
||||
|
||||
/** |
||||
* Get the id for a given chain name, chain id, or domain id |
||||
* @throws if chain's metadata has not been set |
||||
*/ |
||||
getChainId(chainNameOrId: ChainName | number): number { |
||||
return this.getChainMetadata(chainNameOrId).chainId; |
||||
} |
||||
|
||||
/** |
||||
* Get the ids for all chains known to this MultiProvider |
||||
*/ |
||||
getKnownChainIds(): number[] { |
||||
return Object.values(this.metadata).map((c) => c.chainId); |
||||
} |
||||
|
||||
/** |
||||
* Get the domain id for a given chain name, chain id, or domain id |
||||
*/ |
||||
tryGetDomainId(chainNameOrId: ChainName | number): number | null { |
||||
const metadata = this.tryGetChainMetadata(chainNameOrId); |
||||
return metadata?.domainId ?? metadata?.chainId ?? null; |
||||
} |
||||
|
||||
/** |
||||
* Get the domain id for a given chain name, chain id, or domain id |
||||
* @throws if chain's metadata has not been set |
||||
*/ |
||||
getDomainId(chainNameOrId: ChainName | number): number { |
||||
const metadata = this.getChainMetadata(chainNameOrId); |
||||
return getDomainId(metadata); |
||||
} |
||||
|
||||
/** |
||||
* Get the domain ids for a list of chain names, chain ids, or domain ids |
||||
* @throws if any chain's metadata has not been set |
||||
*/ |
||||
getDomainIds(chainNamesOrIds: Array<ChainName | number>): number[] { |
||||
return chainNamesOrIds.map((c) => this.getDomainId(c)); |
||||
} |
||||
|
||||
/** |
||||
* Get the ids for all chains known to this MultiProvider |
||||
*/ |
||||
getKnownDomainIds(): number[] { |
||||
return this.getKnownChainNames().map(this.getDomainId); |
||||
} |
||||
|
||||
/** |
||||
* Get chain names excluding given chain name |
||||
*/ |
||||
getRemoteChains(name: ChainName): ChainName[] { |
||||
return exclude(name, this.getKnownChainNames()); |
||||
} |
||||
|
||||
/** |
||||
* Run given function on all known chains |
||||
*/ |
||||
mapKnownChains<Output>(fn: (n: ChainName) => Output): ChainMap<Output> { |
||||
const result: ChainMap<Output> = {}; |
||||
for (const chain of this.getKnownChainNames()) { |
||||
result[chain] = fn(chain); |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
/** |
||||
* Get an RPC URL for a given chain name, chain id, or domain id |
||||
* @throws if chain's metadata has not been set |
||||
*/ |
||||
getRpcUrl(chainNameOrId: ChainName | number): string { |
||||
const { rpcUrls } = this.getChainMetadata(chainNameOrId); |
||||
if (!rpcUrls?.length || !rpcUrls[0].http) |
||||
throw new Error(`No RPC URl configured for ${chainNameOrId}`); |
||||
return rpcUrls[0].http; |
||||
} |
||||
|
||||
/** |
||||
* Get a block explorer URL for a given chain name, chain id, or domain id |
||||
*/ |
||||
tryGetExplorerUrl(chainNameOrId: ChainName | number): string | null { |
||||
const explorers = this.tryGetChainMetadata(chainNameOrId)?.blockExplorers; |
||||
if (!explorers?.length) return null; |
||||
return explorers[0].url; |
||||
} |
||||
|
||||
/** |
||||
* Get a block explorer URL for a given chain name, chain id, or domain id |
||||
* @throws if chain's metadata or block explorer data has no been set |
||||
*/ |
||||
getExplorerUrl(chainNameOrId: ChainName | number): string { |
||||
const url = this.tryGetExplorerUrl(chainNameOrId); |
||||
if (!url) throw new Error(`No explorer url set for ${chainNameOrId}`); |
||||
return url; |
||||
} |
||||
|
||||
/** |
||||
* Get a block explorer's API URL for a given chain name, chain id, or domain id |
||||
*/ |
||||
tryGetExplorerApiUrl(chainNameOrId: ChainName | number): string | null { |
||||
const explorers = this.tryGetChainMetadata(chainNameOrId)?.blockExplorers; |
||||
if (!explorers?.length || !explorers[0].apiUrl) return null; |
||||
const { apiUrl, apiKey } = explorers[0]; |
||||
if (!apiKey) return apiUrl; |
||||
const url = new URL(apiUrl); |
||||
url.searchParams.set('apikey', apiKey); |
||||
return url.toString(); |
||||
} |
||||
|
||||
/** |
||||
* Get a block explorer API URL for a given chain name, chain id, or domain id |
||||
* @throws if chain's metadata or block explorer data has no been set |
||||
*/ |
||||
getExplorerApiUrl(chainNameOrId: ChainName | number): string { |
||||
const url = this.tryGetExplorerApiUrl(chainNameOrId); |
||||
if (!url) throw new Error(`No explorer api url set for ${chainNameOrId}`); |
||||
return url; |
||||
} |
||||
|
||||
/** |
||||
* Get a block explorer URL for given chain's tx |
||||
*/ |
||||
tryGetExplorerTxUrl( |
||||
chainNameOrId: ChainName | number, |
||||
response: { hash: string }, |
||||
): string | null { |
||||
const baseUrl = this.tryGetExplorerUrl(chainNameOrId); |
||||
return baseUrl ? `${baseUrl}/tx/${response.hash}` : null; |
||||
} |
||||
|
||||
/** |
||||
* Get a block explorer URL for given chain's tx |
||||
* @throws if chain's metadata or block explorer data has no been set |
||||
*/ |
||||
getExplorerTxUrl( |
||||
chainNameOrId: ChainName | number, |
||||
response: { hash: string }, |
||||
): string { |
||||
return `${this.getExplorerUrl(chainNameOrId)}/tx/${response.hash}`; |
||||
} |
||||
|
||||
/** |
||||
* Creates a new ChainMetadataManager with the extended metadata |
||||
* @param additionalMetadata extra fields to add to the metadata for each chain |
||||
* @returns a new ChainMetadataManager |
||||
*/ |
||||
extendChainMetadata<NewExt = {}>( |
||||
additionalMetadata: ChainMap<NewExt>, |
||||
): ChainMetadataManager<MetaExt & NewExt> { |
||||
const newMetadata: ChainMap<ChainMetadata<MetaExt & NewExt>> = {}; |
||||
for (const [name, meta] of Object.entries(this.metadata)) { |
||||
if (!additionalMetadata[name]) |
||||
throw new Error(`No additional data provided for chain ${name}`); |
||||
newMetadata[name] = { ...meta, ...additionalMetadata[name] }; |
||||
} |
||||
return new ChainMetadataManager(newMetadata); |
||||
} |
||||
} |
@ -0,0 +1,26 @@ |
||||
import { expect } from 'chai'; |
||||
|
||||
import { ethereum } from '../consts/chainMetadata'; |
||||
import { Chains } from '../consts/chains'; |
||||
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider'; |
||||
|
||||
describe('MultiProtocolProvider', () => { |
||||
describe('constructs', () => { |
||||
it('creates a multi protocol provider without type extension', async () => { |
||||
const multiProvider = new MultiProtocolProvider(); |
||||
const ethMetadata = multiProvider.getChainMetadata(Chains.ethereum); |
||||
expect(ethMetadata.name).to.equal(Chains.ethereum); |
||||
}); |
||||
it('creates a multi protocol provider with type extension', async () => { |
||||
const multiProvider = new MultiProtocolProvider<{ |
||||
foo: string; |
||||
bar: number; |
||||
}>({ |
||||
[Chains.ethereum]: { ...ethereum, foo: '0x123', bar: 1 }, |
||||
}); |
||||
const ethMetadata = multiProvider.getChainMetadata(Chains.ethereum); |
||||
expect(ethMetadata.foo).to.equal('0x123'); |
||||
expect(ethMetadata.bar).to.equal(1); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,159 @@ |
||||
import { Debugger, debug } from 'debug'; |
||||
|
||||
import { objMap } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { chainMetadata as defaultChainMetadata } from '../consts/chainMetadata'; |
||||
import { ChainMetadataManager } from '../metadata/ChainMetadataManager'; |
||||
import type { ChainMetadata } from '../metadata/chainMetadataTypes'; |
||||
import type { ChainMap, ChainName } from '../types'; |
||||
|
||||
import type { MultiProvider } from './MultiProvider'; |
||||
import { |
||||
EthersV5Provider, |
||||
ProviderMap, |
||||
ProviderType, |
||||
SolanaWeb3Provider, |
||||
TypedProvider, |
||||
ViemProvider, |
||||
} from './ProviderType'; |
||||
import { |
||||
ProviderBuilderMap, |
||||
defaultProviderBuilderMap, |
||||
} from './providerBuilders'; |
||||
|
||||
export interface MultiProtocolProviderOptions { |
||||
loggerName?: string; |
||||
providerBuilders?: Partial<ProviderBuilderMap>; |
||||
} |
||||
|
||||
/** |
||||
* A version of MultiProvider that can support different |
||||
* provider types across different protocol types. |
||||
* |
||||
* This uses a different interface for provider/signer related methods |
||||
* so it isn't strictly backwards compatible with MultiProvider. |
||||
* |
||||
* Unlike MultiProvider, this class does not support signer/signing methods (yet). |
||||
* @typeParam MetaExt - Extra metadata fields for chains (such as contract addresses) |
||||
*/ |
||||
export class MultiProtocolProvider< |
||||
MetaExt = {}, |
||||
> extends ChainMetadataManager<MetaExt> { |
||||
protected readonly providers: ChainMap<ProviderMap<TypedProvider>> = {}; |
||||
protected signers: ChainMap<ProviderMap<never>> = {}; // TODO signer support
|
||||
protected readonly logger: Debugger; |
||||
protected readonly providerBuilders: Partial<ProviderBuilderMap>; |
||||
|
||||
constructor( |
||||
chainMetadata: ChainMap< |
||||
ChainMetadata<MetaExt> |
||||
> = defaultChainMetadata as ChainMap<ChainMetadata<MetaExt>>, |
||||
protected readonly options: MultiProtocolProviderOptions = {}, |
||||
) { |
||||
super(chainMetadata, options); |
||||
this.logger = debug( |
||||
options?.loggerName || 'hyperlane:MultiProtocolProvider', |
||||
); |
||||
this.providerBuilders = |
||||
options.providerBuilders || defaultProviderBuilderMap; |
||||
} |
||||
|
||||
static fromMultiProvider<MetaExt = {}>( |
||||
mp: MultiProvider<MetaExt>, |
||||
options: MultiProtocolProviderOptions = {}, |
||||
): MultiProtocolProvider<MetaExt> { |
||||
const newMp = new MultiProtocolProvider<MetaExt>(mp.metadata, options); |
||||
const typedProviders = objMap(mp.providers, (_, provider) => ({ |
||||
type: ProviderType.EthersV5, |
||||
provider, |
||||
})) as ChainMap<TypedProvider>; |
||||
newMp.setProviders(typedProviders); |
||||
return newMp; |
||||
} |
||||
|
||||
override extendChainMetadata<NewExt = {}>( |
||||
additionalMetadata: ChainMap<NewExt>, |
||||
): MultiProtocolProvider<MetaExt & NewExt> { |
||||
const newMetadata = super.extendChainMetadata(additionalMetadata).metadata; |
||||
return new MultiProtocolProvider(newMetadata, this.options); |
||||
} |
||||
|
||||
tryGetProvider( |
||||
chainNameOrId: ChainName | number, |
||||
type: ProviderType, |
||||
): TypedProvider | null { |
||||
const metadata = this.tryGetChainMetadata(chainNameOrId); |
||||
if (!metadata) return null; |
||||
const { name, chainId, rpcUrls } = metadata; |
||||
|
||||
if (this.providers[name]?.[type]) return this.providers[name][type]!; |
||||
|
||||
const builder = this.providerBuilders[type]; |
||||
if (!rpcUrls.length || !builder) return null; |
||||
|
||||
const provider = builder(rpcUrls, chainId); |
||||
this.providers[name] ||= {}; |
||||
this.providers[name][type] = provider; |
||||
return provider; |
||||
} |
||||
|
||||
getProvider( |
||||
chainNameOrId: ChainName | number, |
||||
type: ProviderType, |
||||
): TypedProvider { |
||||
const provider = this.tryGetProvider(chainNameOrId, type); |
||||
if (!provider) |
||||
throw new Error(`No provider available for ${chainNameOrId}`); |
||||
return provider; |
||||
} |
||||
|
||||
getEthersV5Provider( |
||||
chainNameOrId: ChainName | number, |
||||
): EthersV5Provider['provider'] { |
||||
const provider = this.getProvider(chainNameOrId, ProviderType.EthersV5); |
||||
if (provider.type !== ProviderType.EthersV5) |
||||
throw new Error('Invalid provider type'); |
||||
return provider.provider; |
||||
} |
||||
|
||||
// getEthersV6Provider(
|
||||
// chainNameOrId: ChainName | number,
|
||||
// ): EthersV6Provider['provider'] {
|
||||
// const provider = this.getProvider(chainNameOrId, ProviderType.EthersV5);
|
||||
// if (provider.type !== ProviderType.EthersV6)
|
||||
// throw new Error('Invalid provider type');
|
||||
// return provider.provider;
|
||||
// }
|
||||
|
||||
getViemProvider(chainNameOrId: ChainName | number): ViemProvider['provider'] { |
||||
const provider = this.getProvider(chainNameOrId, ProviderType.EthersV5); |
||||
if (provider.type !== ProviderType.Viem) |
||||
throw new Error('Invalid provider type'); |
||||
return provider.provider; |
||||
} |
||||
|
||||
getSolanaWeb3Provider( |
||||
chainNameOrId: ChainName | number, |
||||
): SolanaWeb3Provider['provider'] { |
||||
const provider = this.getProvider(chainNameOrId, ProviderType.EthersV5); |
||||
if (provider.type !== ProviderType.SolanaWeb3) |
||||
throw new Error('Invalid provider type'); |
||||
return provider.provider; |
||||
} |
||||
|
||||
setProvider( |
||||
chainNameOrId: ChainName | number, |
||||
provider: TypedProvider, |
||||
): TypedProvider { |
||||
const chainName = this.getChainName(chainNameOrId); |
||||
this.providers[chainName] ||= {}; |
||||
this.providers[chainName][provider.type] = provider; |
||||
return provider; |
||||
} |
||||
|
||||
setProviders(providers: ChainMap<TypedProvider>): void { |
||||
for (const chain of Object.keys(providers)) { |
||||
this.setProvider(chain, providers[chain]); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,134 @@ |
||||
import type { |
||||
Connection, |
||||
Transaction as SolTransaction, |
||||
} from '@solana/web3.js'; |
||||
import type { |
||||
Contract as EV5Contract, |
||||
providers as EV5Providers, |
||||
PopulatedTransaction as EV5Transaction, |
||||
} from 'ethers'; |
||||
// import type { Contract as Ev6Contract, Provider as Ev6Provider } from 'ethers6';
|
||||
import type { |
||||
GetContractReturnType, |
||||
PublicClient, |
||||
Transaction as VTransaction, |
||||
} from 'viem'; |
||||
|
||||
export enum ProviderType { |
||||
EthersV5 = 'ethers-v5', |
||||
// EthersV6 = 'ethers-v6', Disabled for now to simplify build tooling
|
||||
Viem = 'viem', |
||||
SolanaWeb3 = 'solana-web3', |
||||
} |
||||
|
||||
export type ProviderMap<Value> = Partial<Record<ProviderType, Value>>; |
||||
|
||||
/** |
||||
* Providers with discriminated union of type |
||||
*/ |
||||
|
||||
interface TypedProviderBase<T> { |
||||
type: ProviderType; |
||||
provider: T; |
||||
} |
||||
|
||||
export interface EthersV5Provider |
||||
extends TypedProviderBase<EV5Providers.Provider> { |
||||
type: ProviderType.EthersV5; |
||||
provider: EV5Providers.Provider; |
||||
} |
||||
|
||||
// export interface EthersV6Provider extends TypedProviderBase<Ev6Provider> {
|
||||
// type: ProviderType.EthersV6;
|
||||
// provider: Ev6Provider;
|
||||
// }
|
||||
|
||||
export interface ViemProvider extends TypedProviderBase<PublicClient> { |
||||
type: ProviderType.Viem; |
||||
provider: PublicClient; |
||||
} |
||||
|
||||
export interface SolanaWeb3Provider extends TypedProviderBase<Connection> { |
||||
type: ProviderType.SolanaWeb3; |
||||
provider: Connection; |
||||
} |
||||
|
||||
export type TypedProvider = |
||||
| EthersV5Provider |
||||
// | EthersV6Provider
|
||||
| ViemProvider |
||||
| SolanaWeb3Provider; |
||||
|
||||
/** |
||||
* Contracts with discriminated union of provider type |
||||
*/ |
||||
|
||||
interface TypedContractBase<T> { |
||||
type: ProviderType; |
||||
contract: T; |
||||
} |
||||
|
||||
export interface EthersV5Contract extends TypedContractBase<EV5Contract> { |
||||
type: ProviderType.EthersV5; |
||||
transaction: EV5Contract; |
||||
} |
||||
|
||||
// export interface EthersV6Contract extends TypedContractBase<Ev6Contract> {
|
||||
// type: ProviderType.EthersV6;
|
||||
// contract: Ev6Contract;
|
||||
// }
|
||||
|
||||
export interface ViemContract extends TypedContractBase<GetContractReturnType> { |
||||
type: ProviderType.Viem; |
||||
transaction: GetContractReturnType; |
||||
} |
||||
|
||||
export interface SolanaWeb3Contract extends TypedContractBase<never> { |
||||
type: ProviderType.SolanaWeb3; |
||||
// Contract concept doesn't exist in @solana/web3.js
|
||||
transaction: never; |
||||
} |
||||
|
||||
export type TypedContract = |
||||
| EthersV5Contract |
||||
// | EthersV6Contract
|
||||
| ViemContract |
||||
| SolanaWeb3Contract; |
||||
|
||||
/** |
||||
* Transactions with discriminated union of provider type |
||||
*/ |
||||
|
||||
interface TypedTransactionBase<T> { |
||||
type: ProviderType; |
||||
transaction: T; |
||||
} |
||||
|
||||
export interface EthersV5Transaction |
||||
extends TypedTransactionBase<EV5Transaction> { |
||||
type: ProviderType.EthersV5; |
||||
transaction: EV5Transaction; |
||||
} |
||||
|
||||
// export interface EthersV6Transaction extends TypedTransactionBase<Ev6Transaction> {
|
||||
// type: ProviderType.EthersV6;
|
||||
// contract: Ev6Transaction;
|
||||
// }
|
||||
|
||||
export interface ViemTransaction extends TypedTransactionBase<VTransaction> { |
||||
type: ProviderType.Viem; |
||||
transaction: VTransaction; |
||||
} |
||||
|
||||
export interface SolanaWeb3Transaction |
||||
extends TypedTransactionBase<SolTransaction> { |
||||
type: ProviderType.SolanaWeb3; |
||||
// Transaction concept doesn't exist in @solana/web3.js
|
||||
transaction: SolTransaction; |
||||
} |
||||
|
||||
export type TypedTransaction = |
||||
| EthersV5Transaction |
||||
// | EthersV6Transaction
|
||||
| ViemTransaction |
||||
| SolanaWeb3Transaction; |
@ -0,0 +1,131 @@ |
||||
import { Connection } from '@solana/web3.js'; |
||||
import { providers } from 'ethers'; |
||||
import { createPublicClient, http } from 'viem'; |
||||
|
||||
import { ProtocolType, isNumeric } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ChainMetadata } from '../metadata/chainMetadataTypes'; |
||||
|
||||
import { |
||||
EthersV5Provider, |
||||
ProviderType, |
||||
SolanaWeb3Provider, |
||||
TypedProvider, |
||||
ViemProvider, |
||||
} from './ProviderType'; |
||||
import { RetryJsonRpcProvider, RetryProviderOptions } from './RetryProvider'; |
||||
|
||||
export type ProviderBuilderFn<P> = ( |
||||
rpcUrls: ChainMetadata['rpcUrls'], |
||||
network: number | string, |
||||
retryOverride?: RetryProviderOptions, |
||||
) => P; |
||||
export type TypedProviderBuilderFn = ProviderBuilderFn<TypedProvider>; |
||||
|
||||
export const DEFAULT_RETRY_OPTIONS: RetryProviderOptions = { |
||||
maxRequests: 3, |
||||
baseRetryMs: 250, |
||||
}; |
||||
|
||||
export function defaultEthersV5ProviderBuilder( |
||||
rpcUrls: ChainMetadata['rpcUrls'], |
||||
network: number | string, |
||||
retryOverride?: RetryProviderOptions, |
||||
): EthersV5Provider { |
||||
const createProvider = (r: ChainMetadata['rpcUrls'][number]) => { |
||||
const retry = r.retry || retryOverride; |
||||
return retry |
||||
? new RetryJsonRpcProvider(retry, r.http, network) |
||||
: new providers.StaticJsonRpcProvider(r.http, network); |
||||
}; |
||||
let provider: providers.Provider; |
||||
if (rpcUrls.length > 1) { |
||||
provider = new providers.FallbackProvider(rpcUrls.map(createProvider), 1); |
||||
} else if (rpcUrls.length === 1) { |
||||
provider = createProvider(rpcUrls[0]); |
||||
} else { |
||||
throw new Error('No RPC URLs provided'); |
||||
} |
||||
return { type: ProviderType.EthersV5, provider }; |
||||
} |
||||
|
||||
// export function defaultEthersV6ProviderBuilder(
|
||||
// rpcUrls: ChainMetadata['rpcUrls'],
|
||||
// network: number | string,
|
||||
// ): EthersV6Provider {
|
||||
// // TODO add support for retry providers here
|
||||
// if (!rpcUrls.length) throw new Error('No RPC URLs provided');
|
||||
// return {
|
||||
// type: ProviderType.EthersV6,
|
||||
// provider: new Ev6JsonRpcProvider(rpcUrls[0].http, network),
|
||||
// };
|
||||
// }
|
||||
|
||||
export function defaultViemProviderBuilder( |
||||
rpcUrls: ChainMetadata['rpcUrls'], |
||||
network: number | string, |
||||
): ViemProvider { |
||||
if (!rpcUrls.length) throw new Error('No RPC URLs provided'); |
||||
if (!isNumeric(network)) throw new Error('Viem requires a numeric network'); |
||||
const id = parseInt(network.toString(), 10); |
||||
const name = network.toString(); // TODO get more descriptive name
|
||||
const url = rpcUrls[0].http; |
||||
const client = createPublicClient({ |
||||
chain: { |
||||
id, |
||||
name, |
||||
network: name, |
||||
nativeCurrency: { name: '', symbol: '', decimals: 0 }, |
||||
rpcUrls: { default: { http: [url] }, public: { http: [url] } }, |
||||
}, |
||||
transport: http(rpcUrls[0].http), |
||||
}); |
||||
return { type: ProviderType.Viem, provider: client }; |
||||
} |
||||
|
||||
export function defaultSolProviderBuilder( |
||||
rpcUrls: ChainMetadata['rpcUrls'], |
||||
_network: number | string, |
||||
): SolanaWeb3Provider { |
||||
if (!rpcUrls.length) throw new Error('No RPC URLs provided'); |
||||
return { |
||||
type: ProviderType.SolanaWeb3, |
||||
provider: new Connection(rpcUrls[0].http, 'confirmed'), |
||||
}; |
||||
} |
||||
|
||||
export function defaultFuelProviderBuilder( |
||||
rpcUrls: ChainMetadata['rpcUrls'], |
||||
_network: number | string, |
||||
): EthersV5Provider { |
||||
if (!rpcUrls.length) throw new Error('No RPC URLs provided'); |
||||
throw new Error('TODO fuel support'); |
||||
} |
||||
|
||||
// Kept for backwards compatibility
|
||||
export function defaultProviderBuilder( |
||||
rpcUrls: ChainMetadata['rpcUrls'], |
||||
_network: number | string, |
||||
): providers.Provider { |
||||
return defaultEthersV5ProviderBuilder(rpcUrls, _network).provider; |
||||
} |
||||
|
||||
export type ProviderBuilderMap = Record< |
||||
ProviderType, |
||||
ProviderBuilderFn<TypedProvider> |
||||
>; |
||||
export const defaultProviderBuilderMap: ProviderBuilderMap = { |
||||
[ProviderType.EthersV5]: defaultEthersV5ProviderBuilder, |
||||
// [ProviderType.EthersV6]: defaultEthersV6ProviderBuilder,
|
||||
[ProviderType.Viem]: defaultViemProviderBuilder, |
||||
[ProviderType.SolanaWeb3]: defaultSolProviderBuilder, |
||||
}; |
||||
|
||||
export const protocolToDefaultProviderBuilder: Record< |
||||
ProtocolType, |
||||
ProviderBuilderFn<TypedProvider> |
||||
> = { |
||||
[ProtocolType.Ethereum]: defaultEthersV5ProviderBuilder, |
||||
[ProtocolType.Sealevel]: defaultSolProviderBuilder, |
||||
[ProtocolType.Fuel]: defaultFuelProviderBuilder, |
||||
}; |
@ -0,0 +1,22 @@ |
||||
import { expect } from 'chai'; |
||||
|
||||
import { Address } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { Chains } from '../consts/chains'; |
||||
import { MultiProtocolProvider } from '../providers/MultiProtocolProvider'; |
||||
|
||||
import { MultiProtocolRouterApp } from './MultiProtocolRouterApps'; |
||||
import { EvmRouterAdapter } from './adapters/EvmRouterAdapter'; |
||||
|
||||
describe('MultiProtocolRouterApp', () => { |
||||
describe('constructs', () => { |
||||
const multiProvider = new MultiProtocolProvider<{ router: Address }>(); |
||||
it('creates an app class', async () => { |
||||
const app = new MultiProtocolRouterApp(multiProvider); |
||||
expect(app).to.be.instanceOf(MultiProtocolRouterApp); |
||||
const ethAdapter = app.adapter(Chains.ethereum); |
||||
expect(ethAdapter).to.be.instanceOf(EvmRouterAdapter); |
||||
expect(!!ethAdapter.remoteRouter).to.be.true; |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,64 @@ |
||||
import { Address, Domain, ProtocolType } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { MultiProtocolApp } from '../app/MultiProtocolApp'; |
||||
import { ChainMap, ChainName } from '../types'; |
||||
|
||||
import { |
||||
EvmGasRouterAdapter, |
||||
EvmRouterAdapter, |
||||
} from './adapters/EvmRouterAdapter'; |
||||
import { |
||||
SealevelGasRouterAdapter, |
||||
SealevelRouterAdapter, |
||||
} from './adapters/SealevelRouterAdapter'; |
||||
import { IGasRouterAdapter, IRouterAdapter } from './adapters/types'; |
||||
import { RouterAddress } from './types'; |
||||
|
||||
export { Router } from '@hyperlane-xyz/core'; |
||||
|
||||
export class MultiProtocolRouterApp< |
||||
ContractAddrs extends RouterAddress = RouterAddress, |
||||
IAdapterApi extends IRouterAdapter = IRouterAdapter, |
||||
> extends MultiProtocolApp<ContractAddrs, IAdapterApi> { |
||||
public override readonly protocolToAdapter = { |
||||
[ProtocolType.Ethereum]: EvmRouterAdapter, |
||||
[ProtocolType.Sealevel]: SealevelRouterAdapter, |
||||
}; |
||||
|
||||
router(chain: ChainName): Address { |
||||
return this.metadata(chain).router; |
||||
} |
||||
|
||||
interchainSecurityModules(): Promise<ChainMap<Address>> { |
||||
return this.adapterMap((chain, adapter) => |
||||
adapter.interchainSecurityModule(chain), |
||||
); |
||||
} |
||||
|
||||
owners(): Promise<ChainMap<Address>> { |
||||
return this.adapterMap((chain, adapter) => adapter.owner(chain)); |
||||
} |
||||
|
||||
remoteRouters( |
||||
origin: ChainName, |
||||
): Promise<Array<{ domain: Domain; address: Address }>> { |
||||
return this.adapter(origin).remoteRouters(origin); |
||||
} |
||||
} |
||||
|
||||
export class MultiProtocolGasRouterApp< |
||||
ContractAddrs extends RouterAddress = RouterAddress, |
||||
IAdapterApi extends IGasRouterAdapter = IGasRouterAdapter, |
||||
> extends MultiProtocolRouterApp<ContractAddrs, IAdapterApi> { |
||||
public override readonly protocolToAdapter = { |
||||
[ProtocolType.Ethereum]: EvmGasRouterAdapter, |
||||
[ProtocolType.Sealevel]: SealevelGasRouterAdapter, |
||||
}; |
||||
|
||||
async quoteGasPayment( |
||||
origin: ChainName, |
||||
destination: ChainName, |
||||
): Promise<string> { |
||||
return this.adapter(origin).quoteGasPayment(origin, destination); |
||||
} |
||||
} |
@ -0,0 +1,89 @@ |
||||
import { |
||||
GasRouter, |
||||
GasRouter__factory, |
||||
Router, |
||||
Router__factory, |
||||
} from '@hyperlane-xyz/core'; |
||||
import { Address, Domain, bytes32ToAddress } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { BaseEvmAdapter } from '../../app/MultiProtocolApp'; |
||||
import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider'; |
||||
import { ChainName } from '../../types'; |
||||
import { RouterAddress } from '../types'; |
||||
|
||||
import { IGasRouterAdapter, IRouterAdapter } from './types'; |
||||
|
||||
export class EvmRouterAdapter< |
||||
ContractAddrs extends RouterAddress = RouterAddress, |
||||
> |
||||
extends BaseEvmAdapter<ContractAddrs> |
||||
implements IRouterAdapter<ContractAddrs> |
||||
{ |
||||
constructor( |
||||
public readonly multiProvider: MultiProtocolProvider<ContractAddrs>, |
||||
) { |
||||
super(multiProvider); |
||||
} |
||||
|
||||
interchainSecurityModule(chain: ChainName): Promise<Address> { |
||||
return this.getConnectedContract(chain).interchainSecurityModule(); |
||||
} |
||||
|
||||
owner(chain: ChainName): Promise<Address> { |
||||
return this.getConnectedContract(chain).owner(); |
||||
} |
||||
|
||||
remoteDomains(originChain: ChainName): Promise<Domain[]> { |
||||
return this.getConnectedContract(originChain).domains(); |
||||
} |
||||
|
||||
async remoteRouter( |
||||
originChain: ChainName, |
||||
remoteDomain: Domain, |
||||
): Promise<Address> { |
||||
const routerAddressesAsBytes32 = await this.getConnectedContract( |
||||
originChain, |
||||
).routers(remoteDomain); |
||||
return bytes32ToAddress(routerAddressesAsBytes32); |
||||
} |
||||
|
||||
async remoteRouters( |
||||
originChain: ChainName, |
||||
): Promise<Array<{ domain: Domain; address: Address }>> { |
||||
const domains = await this.remoteDomains(originChain); |
||||
const routers: Address[] = await Promise.all( |
||||
domains.map((d) => this.remoteRouter(originChain, d)), |
||||
); |
||||
return domains.map((d, i) => ({ domain: d, address: routers[i] })); |
||||
} |
||||
|
||||
getConnectedContract(chain: ChainName): Router { |
||||
const address = this.multiProvider.getChainMetadata(chain).router; |
||||
const provider = this.multiProvider.getEthersV5Provider(chain); |
||||
return Router__factory.connect(address, provider); |
||||
} |
||||
} |
||||
|
||||
export class EvmGasRouterAdapter< |
||||
ContractAddrs extends RouterAddress = RouterAddress, |
||||
> |
||||
extends EvmRouterAdapter<ContractAddrs> |
||||
implements IGasRouterAdapter<ContractAddrs> |
||||
{ |
||||
async quoteGasPayment( |
||||
origin: ChainName, |
||||
destination: ChainName, |
||||
): Promise<string> { |
||||
const destDomain = this.multiProvider.getDomainId(destination); |
||||
const amount = await this.getConnectedContract(origin).quoteGasPayment( |
||||
destDomain, |
||||
); |
||||
return amount.toString(); |
||||
} |
||||
|
||||
override getConnectedContract(chain: ChainName): GasRouter { |
||||
const address = this.multiProvider.getChainMetadata(chain).router; |
||||
const provider = this.multiProvider.getEthersV5Provider(chain); |
||||
return GasRouter__factory.connect(address, provider); |
||||
} |
||||
} |
@ -0,0 +1,30 @@ |
||||
import { deserializeUnchecked } from 'borsh'; |
||||
import { expect } from 'chai'; |
||||
|
||||
import { |
||||
SealevelAccountDataWrapper, |
||||
SealevelTokenDataSchema, |
||||
} from './SealevelRouterAdapter'; |
||||
|
||||
// Copied from the warp token router program on Solana devnet
|
||||
const RAW_ACCOUNT_INFO = |
||||
'01fe3a280e8466d26bc4e1a5d3d17e73f7b307c082156dd0ffbf8c5f9ae75506d6f1e9fd9f0b53dbdafc27f42dc88acef25b6cea358c214ced8144165b842148eddbfe0606014d8d3746cf8844ccba8c2f0662b0f1be404229c75c88c93985bd07881eea18f5000200000069a80000000000000000000000000000a97f4eacbc363f82d25a540440afc6f78920299b742d627aa7b75e9d68d3a8f9a84720e039cbb1f9ef1c515f2b3aa99619ecb6f07792bde406ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9e92839550965ffd4d64acaaf46d45df7318e5b4f57c90c487d60625d829b837b36b9e506fe298843d50359492a8b4ce739c8fa85da27ac0bb53a1ad5fa66fcc9fffd00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; |
||||
|
||||
const OWNER_PUB_KEY = '6DjHX6Ezjpq3zZMZ8KsqyoFYo1zPSDoiZmLLkxD4xKXS'; |
||||
|
||||
describe('SealevelRouterAdapter', () => { |
||||
describe('account info', () => { |
||||
it('correctly deserializes router account info', () => { |
||||
const rawData = Buffer.from(RAW_ACCOUNT_INFO, 'hex'); |
||||
const accountData = deserializeUnchecked( |
||||
SealevelTokenDataSchema, |
||||
SealevelAccountDataWrapper, |
||||
rawData, |
||||
); |
||||
expect(accountData.initialized).to.eql(1); |
||||
expect(accountData.data.decimals).to.eql(6); |
||||
expect(accountData.data.owner_pub_key?.toBase58()).to.eql(OWNER_PUB_KEY); |
||||
expect(accountData.data.remote_router_pubkeys.size).to.eql(2); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,197 @@ |
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ |
||||
import { PublicKey } from '@solana/web3.js'; |
||||
import { deserializeUnchecked } from 'borsh'; |
||||
|
||||
import { Address, Domain } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { BaseSealevelAdapter } from '../../app/MultiProtocolApp'; |
||||
import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider'; |
||||
import { ChainName } from '../../types'; |
||||
import { RouterAddress } from '../types'; |
||||
|
||||
import { IGasRouterAdapter, IRouterAdapter } from './types'; |
||||
|
||||
// Hyperlane Token Borsh Schema
|
||||
export class SealevelAccountDataWrapper { |
||||
initialized!: boolean; |
||||
data!: SealevelTokenData; |
||||
constructor(public readonly fields: any) { |
||||
Object.assign(this, fields); |
||||
} |
||||
} |
||||
|
||||
// Should match https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/trevor/sealevel-validator-rebase/rust/sealevel/libraries/hyperlane-sealevel-token/src/accounts.rs#L21
|
||||
export class SealevelTokenData { |
||||
/// The bump seed for this PDA.
|
||||
bump!: number; |
||||
/// The address of the mailbox contract.
|
||||
mailbox!: Uint8Array; |
||||
mailbox_pubkey!: PublicKey; |
||||
/// The Mailbox process authority specific to this program as the recipient.
|
||||
mailbox_process_authority!: Uint8Array; |
||||
mailbox_process_authority_pubkey!: PublicKey; |
||||
/// The dispatch authority PDA's bump seed.
|
||||
dispatch_authority_bump!: number; |
||||
/// The decimals of the local token.
|
||||
decimals!: number; |
||||
/// The decimals of the remote token.
|
||||
remote_decimals!: number; |
||||
/// Access control owner.
|
||||
owner?: Uint8Array; |
||||
owner_pub_key?: PublicKey; |
||||
/// The interchain security module.
|
||||
interchain_security_module?: Uint8Array; |
||||
interchain_security_module_pubkey?: PublicKey; |
||||
/// Remote routers.
|
||||
remote_routers?: Map<Domain, Uint8Array>; |
||||
remote_router_pubkeys: Map<Domain, PublicKey>; |
||||
constructor(public readonly fields: any) { |
||||
Object.assign(this, fields); |
||||
this.mailbox_pubkey = new PublicKey(this.mailbox); |
||||
this.mailbox_pubkey = new PublicKey(this.mailbox_process_authority); |
||||
this.owner_pub_key = this.owner ? new PublicKey(this.owner) : undefined; |
||||
this.interchain_security_module_pubkey = this.interchain_security_module |
||||
? new PublicKey(this.interchain_security_module) |
||||
: undefined; |
||||
this.remote_router_pubkeys = new Map<number, PublicKey>(); |
||||
if (this.remote_routers) { |
||||
for (const [k, v] of this.remote_routers.entries()) { |
||||
this.remote_router_pubkeys.set(k, new PublicKey(v)); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
export const SealevelTokenDataSchema = new Map<any, any>([ |
||||
[ |
||||
SealevelAccountDataWrapper, |
||||
{ |
||||
kind: 'struct', |
||||
fields: [ |
||||
['initialized', 'u8'], |
||||
['data', SealevelTokenData], |
||||
], |
||||
}, |
||||
], |
||||
[ |
||||
SealevelTokenData, |
||||
{ |
||||
kind: 'struct', |
||||
fields: [ |
||||
['bump', 'u8'], |
||||
['mailbox', [32]], |
||||
['mailbox_process_authority', [32]], |
||||
['dispatch_authority_bump', 'u8'], |
||||
['decimals', 'u8'], |
||||
['remote_decimals', 'u8'], |
||||
['owner', { kind: 'option', type: [32] }], |
||||
['interchain_security_module', { kind: 'option', type: [32] }], |
||||
['remote_routers', { kind: 'map', key: 'u32', value: [32] }], |
||||
], |
||||
}, |
||||
], |
||||
]); |
||||
|
||||
export class SealevelRouterAdapter< |
||||
ContractAddrs extends RouterAddress = RouterAddress, |
||||
> |
||||
extends BaseSealevelAdapter<ContractAddrs> |
||||
implements IRouterAdapter<ContractAddrs> |
||||
{ |
||||
constructor( |
||||
public readonly multiProvider: MultiProtocolProvider<ContractAddrs>, |
||||
) { |
||||
super(multiProvider); |
||||
} |
||||
|
||||
async interchainSecurityModule(chain: ChainName): Promise<Address> { |
||||
const routerAccountInfo = await this.getRouterAccountInfo(chain); |
||||
if (!routerAccountInfo.interchain_security_module_pubkey) |
||||
throw new Error(`No ism found for router on ${chain}`); |
||||
return routerAccountInfo.interchain_security_module_pubkey.toBase58(); |
||||
} |
||||
|
||||
async owner(chain: ChainName): Promise<Address> { |
||||
const routerAccountInfo = await this.getRouterAccountInfo(chain); |
||||
if (!routerAccountInfo.owner_pub_key) |
||||
throw new Error(`No owner found for router on ${chain}`); |
||||
return routerAccountInfo.owner_pub_key.toBase58(); |
||||
} |
||||
|
||||
async remoteDomains(originChain: ChainName): Promise<Domain[]> { |
||||
const routers = await this.remoteRouters(originChain); |
||||
return routers.map((router) => router.domain); |
||||
} |
||||
|
||||
async remoteRouter( |
||||
originChain: ChainName, |
||||
remoteDomain: Domain, |
||||
): Promise<Address> { |
||||
const routers = await this.remoteRouters(originChain); |
||||
const addr = routers.find( |
||||
(router) => router.domain === remoteDomain, |
||||
)?.address; |
||||
if (!addr) throw new Error(`No router found for ${remoteDomain}`); |
||||
return addr; |
||||
} |
||||
|
||||
async remoteRouters( |
||||
originChain: ChainName, |
||||
): Promise<Array<{ domain: Domain; address: Address }>> { |
||||
const routerAccountInfo = await this.getRouterAccountInfo(originChain); |
||||
const domainToPubKey = routerAccountInfo.remote_router_pubkeys; |
||||
return Array.from(domainToPubKey.entries()).map(([domain, pubKey]) => ({ |
||||
domain, |
||||
address: pubKey.toBase58(), |
||||
})); |
||||
} |
||||
|
||||
// TODO this incorrectly assumes all sealevel routers will have the TokenRouter's data schema
|
||||
// This will need to change when other types of routers are supported
|
||||
async getRouterAccountInfo(chain: ChainName): Promise<SealevelTokenData> { |
||||
const address = this.multiProvider.getChainMetadata(chain).router; |
||||
const connection = this.multiProvider.getSolanaWeb3Provider(chain); |
||||
|
||||
const msgRecipientPda = this.deriveMessageRecipientPda(address); |
||||
const accountInfo = await connection.getAccountInfo(msgRecipientPda); |
||||
if (!accountInfo) |
||||
throw new Error( |
||||
`No account info found for ${msgRecipientPda.toBase58()}}`, |
||||
); |
||||
const accountData = deserializeUnchecked( |
||||
SealevelTokenDataSchema, |
||||
SealevelAccountDataWrapper, |
||||
accountInfo.data, |
||||
); |
||||
return accountData.data; |
||||
} |
||||
|
||||
// Should match https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/trevor/sealevel-validator-rebase/rust/sealevel/libraries/hyperlane-sealevel-token/src/processor.rs#LL49C1-L53C30
|
||||
deriveMessageRecipientPda(routerAddress: Address): PublicKey { |
||||
const [pda] = PublicKey.findProgramAddressSync( |
||||
[ |
||||
Buffer.from('hyperlane_message_recipient'), |
||||
Buffer.from('-'), |
||||
Buffer.from('handle'), |
||||
Buffer.from('-'), |
||||
Buffer.from('account_metas'), |
||||
], |
||||
new PublicKey(routerAddress), |
||||
); |
||||
return pda; |
||||
} |
||||
} |
||||
|
||||
export class SealevelGasRouterAdapter< |
||||
ContractAddrs extends RouterAddress = RouterAddress, |
||||
> |
||||
extends SealevelRouterAdapter<ContractAddrs> |
||||
implements IGasRouterAdapter<ContractAddrs> |
||||
{ |
||||
async quoteGasPayment( |
||||
_origin: ChainName, |
||||
_destination: ChainName, |
||||
): Promise<string> { |
||||
throw new Error('Gas payments not yet supported for sealevel'); |
||||
} |
||||
} |
@ -0,0 +1,29 @@ |
||||
import { Address, Domain } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { BaseAppAdapter } from '../../app/MultiProtocolApp'; |
||||
import { ChainName } from '../../types'; |
||||
import { RouterAddress } from '../types'; |
||||
|
||||
export interface IRouterAdapter< |
||||
ContractAddrs extends RouterAddress = RouterAddress, |
||||
> extends BaseAppAdapter<ContractAddrs> { |
||||
interchainSecurityModule(chain: ChainName): Promise<Address>; |
||||
owner: (chain: ChainName) => Promise<Address>; |
||||
remoteDomains(originChain: ChainName): Promise<Domain[]>; |
||||
remoteRouter: ( |
||||
originChain: ChainName, |
||||
remoteDomain: Domain, |
||||
) => Promise<Address>; |
||||
remoteRouters: ( |
||||
originChain: ChainName, |
||||
) => Promise<Array<{ domain: Domain; address: Address }>>; |
||||
} |
||||
|
||||
export interface IGasRouterAdapter< |
||||
ContractAddrs extends RouterAddress = RouterAddress, |
||||
> extends IRouterAdapter<ContractAddrs> { |
||||
quoteGasPayment: ( |
||||
origin: ChainName, |
||||
destination: ChainName, |
||||
) => Promise<string>; |
||||
} |
@ -1,31 +1,5 @@ |
||||
{ |
||||
"env": { |
||||
"node": true, |
||||
"browser": true, |
||||
"es2021": true |
||||
}, |
||||
"root": true, |
||||
"parser": "@typescript-eslint/parser", |
||||
"parserOptions": { |
||||
"ecmaVersion": 12, |
||||
"sourceType": "module", |
||||
"project": "./tsconfig.json" |
||||
}, |
||||
"plugins": ["@typescript-eslint"], |
||||
"extends": [ |
||||
"eslint:recommended", |
||||
"plugin:@typescript-eslint/recommended", |
||||
"prettier" |
||||
], |
||||
"rules": { |
||||
"no-eval": ["error"], |
||||
"no-ex-assign": ["error"], |
||||
"no-constant-condition": ["off"], |
||||
"@typescript-eslint/ban-ts-comment": ["off"], |
||||
"@typescript-eslint/explicit-module-boundary-types": ["off"], |
||||
"@typescript-eslint/no-explicit-any": ["off"], |
||||
"@typescript-eslint/no-floating-promises": ["off"], |
||||
"@typescript-eslint/no-non-null-assertion": ["off"], |
||||
"@typescript-eslint/no-require-imports": ["warn"] |
||||
"no-console": ["off"] |
||||
} |
||||
} |
||||
|
Loading…
Reference in new issue