From f0d767720358e0eb4f422a8b2c9f5ea7eb7d0fcf Mon Sep 17 00:00:00 2001 From: ljankovic-txfusion Date: Fri, 9 May 2025 11:43:43 +0200 Subject: [PATCH] feat/starknet-sdk-integration --- .changeset/hip-papayas-kiss.md | 5 + typescript/sdk/package.json | 1 + typescript/sdk/src/app/MultiProtocolApp.ts | 9 + typescript/sdk/src/consts/testChains.ts | 19 ++ typescript/sdk/src/core/MultiProtocolCore.ts | 2 + .../src/core/adapters/StarknetCoreAdapter.ts | 127 ++++++++ typescript/sdk/src/index.ts | 14 + .../src/providers/MultiProtocolProvider.ts | 10 + typescript/sdk/src/providers/MultiProvider.ts | 2 +- typescript/sdk/src/providers/ProviderType.ts | 7 +- typescript/sdk/src/providers/rpcHealthTest.ts | 13 + .../src/providers/transactionFeeEstimators.ts | 21 ++ typescript/sdk/src/token/Token.ts | 17 + .../token/adapters/StarknetTokenAdapter.ts | 293 ++++++++++++++++++ typescript/sdk/src/utils/starknet.ts | 129 ++++++++ typescript/sdk/src/warp/WarpCore.ts | 5 + .../sdk/src/warp/test-warp-core-config.yaml | 8 + yarn.lock | 3 +- 18 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 .changeset/hip-papayas-kiss.md create mode 100644 typescript/sdk/src/core/adapters/StarknetCoreAdapter.ts create mode 100644 typescript/sdk/src/token/adapters/StarknetTokenAdapter.ts create mode 100644 typescript/sdk/src/utils/starknet.ts diff --git a/.changeset/hip-papayas-kiss.md b/.changeset/hip-papayas-kiss.md new file mode 100644 index 00000000000..8ed6b86f2d6 --- /dev/null +++ b/.changeset/hip-papayas-kiss.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': minor +--- + +feat: Starknet SDK logic integration diff --git a/typescript/sdk/package.json b/typescript/sdk/package.json index a2af98c171c..526deb73edb 100644 --- a/typescript/sdk/package.json +++ b/typescript/sdk/package.json @@ -10,6 +10,7 @@ "@cosmjs/stargate": "^0.32.4", "@hyperlane-xyz/core": "7.1.4", "@hyperlane-xyz/cosmos-sdk": "12.5.0", + "@hyperlane-xyz/starknet-core": "1.0.0", "@hyperlane-xyz/utils": "12.5.0", "@safe-global/api-kit": "1.3.0", "@safe-global/protocol-kit": "1.3.0", diff --git a/typescript/sdk/src/app/MultiProtocolApp.ts b/typescript/sdk/src/app/MultiProtocolApp.ts index 8f2baa322e2..d5426971308 100644 --- a/typescript/sdk/src/app/MultiProtocolApp.ts +++ b/typescript/sdk/src/app/MultiProtocolApp.ts @@ -17,6 +17,7 @@ import { CosmJsWasmProvider, EthersV5Provider, SolanaWeb3Provider, + StarknetJsProvider, TypedProvider, } from '../providers/ProviderType.js'; import { ChainMap, ChainName } from '../types.js'; @@ -104,6 +105,14 @@ export class BaseSealevelAdapter extends BaseAppAdapter { } } +export class BaseStarknetAdapter extends BaseAppAdapter { + public readonly protocol: ProtocolType = ProtocolType.Starknet; + + public getProvider(): StarknetJsProvider['provider'] { + return this.multiProvider.getStarknetProvider(this.chainName); + } +} + /** * A version of HyperlaneApp that can support different * provider types across different protocol types. diff --git a/typescript/sdk/src/consts/testChains.ts b/typescript/sdk/src/consts/testChains.ts index a983e145fd2..b63e48ce490 100644 --- a/typescript/sdk/src/consts/testChains.ts +++ b/typescript/sdk/src/consts/testChains.ts @@ -143,6 +143,24 @@ export const testSealevelChain: ChainMetadata = { rpcUrls: [{ http: 'http://127.0.0.1:8899' }], }; +export const testStarknetChain: ChainMetadata = { + chainId: '0x534e5f5345504f4c4941', + domainId: 5854809, + name: 'starknetdevnet', + nativeToken: { + decimals: 18, + denom: '0x49D36570D4E46F48E99674BD3FCC84644DDD6B96F7C741B1562B82F9E004DC7', + name: 'Ether', + symbol: 'ETH', + }, + protocol: ProtocolType.Starknet, + rpcUrls: [ + { + http: 'http://127.0.0.1:5050', + }, + ], +}; + export const multiProtocolTestChainMetadata: ChainMap = { ...testChainMetadata, testcosmos: testCosmosChain, @@ -150,6 +168,7 @@ export const multiProtocolTestChainMetadata: ChainMap = { testxerc20: testXERC20, testvsxerc20: testVSXERC20, testxerc20lockbox: testXERC20Lockbox, + starknetdevnet: testStarknetChain, }; export const multiProtocolTestChains: Array = Object.keys( diff --git a/typescript/sdk/src/core/MultiProtocolCore.ts b/typescript/sdk/src/core/MultiProtocolCore.ts index f230d4c7b68..c5e5d31893e 100644 --- a/typescript/sdk/src/core/MultiProtocolCore.ts +++ b/typescript/sdk/src/core/MultiProtocolCore.ts @@ -9,6 +9,7 @@ import { CosmNativeCoreAdapter } from './adapters/CosmNativeCoreAdapter.js'; import { CosmWasmCoreAdapter } from './adapters/CosmWasmCoreAdapter.js'; import { EvmCoreAdapter } from './adapters/EvmCoreAdapter.js'; import { SealevelCoreAdapter } from './adapters/SealevelCoreAdapter.js'; +import { StarknetCoreAdapter } from './adapters/StarknetCoreAdapter.js'; import { ICoreAdapter } from './adapters/types.js'; import { CoreAddresses } from './contracts.js'; @@ -41,6 +42,7 @@ export class MultiProtocolCore extends MultiProtocolApp< if (protocol === ProtocolType.Sealevel) return SealevelCoreAdapter; if (protocol === ProtocolType.Cosmos) return CosmWasmCoreAdapter; if (protocol === ProtocolType.CosmosNative) return CosmNativeCoreAdapter; + if (protocol === ProtocolType.Starknet) return StarknetCoreAdapter; throw new Error(`No adapter for protocol ${protocol}`); } diff --git a/typescript/sdk/src/core/adapters/StarknetCoreAdapter.ts b/typescript/sdk/src/core/adapters/StarknetCoreAdapter.ts new file mode 100644 index 00000000000..f0dc96fbdf2 --- /dev/null +++ b/typescript/sdk/src/core/adapters/StarknetCoreAdapter.ts @@ -0,0 +1,127 @@ +import { + CallData, + InvokeTransactionReceiptResponse, + ParsedEvents, + events as eventsUtils, +} from 'starknet'; + +import { getCompiledContract } from '@hyperlane-xyz/starknet-core'; +import { Address, HexString, pollAsync } from '@hyperlane-xyz/utils'; + +import { BaseStarknetAdapter } from '../../app/MultiProtocolApp.js'; +import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js'; +import { + ProviderType, + StarknetJsTransactionReceipt, +} from '../../providers/ProviderType.js'; +import { ChainName } from '../../types.js'; +import { + getStarknetMailboxContract, + parseStarknetDispatchEvents, +} from '../../utils/starknet.js'; + +import { ICoreAdapter } from './types.js'; + +export class StarknetCoreAdapter + extends BaseStarknetAdapter + implements ICoreAdapter +{ + constructor( + public readonly chainName: ChainName, + public readonly multiProvider: MultiProtocolProvider, + public readonly addresses: { mailbox: Address }, + ) { + super(chainName, multiProvider, addresses); + } + + extractMessageIds( + sourceTx: StarknetJsTransactionReceipt, + ): Array<{ messageId: string; destination: ChainName }> { + if (sourceTx.type !== ProviderType.Starknet) { + throw new Error( + `Unsupported provider type for StarknetCoreAdapter ${sourceTx.type}`, + ); + } + + let parsedEvents: ParsedEvents = []; + sourceTx.receipt.match({ + success: (txR) => { + const emittedEvents = + (txR as InvokeTransactionReceiptResponse).events?.map((event) => { + return { + block_hash: (txR as any).block_hash, + block_number: (txR as any).block_number, + transaction_hash: (txR as any).transaction_hash, + ...event, + }; + }) || []; + + if (emittedEvents.length === 0) return; + const mailboxAbi = getCompiledContract('mailbox').abi; + parsedEvents = eventsUtils.parseEvents( + emittedEvents, + eventsUtils.getAbiEvents(mailboxAbi), + CallData.getAbiStruct(mailboxAbi), + CallData.getAbiEnum(mailboxAbi), + ); + }, + _: () => { + throw Error('This transaction was not successful.'); + }, + }); + + if (!parsedEvents || parsedEvents.length === 0) return []; + + const messages = parseStarknetDispatchEvents( + parsedEvents, + (domain) => this.multiProvider.tryGetChainName(domain) ?? undefined, + ); + + return messages.map(({ id, parsed }) => ({ + messageId: id, + destination: this.multiProvider.getChainName(parsed.destination), + })); + } + + async waitForMessageProcessed( + messageId: HexString, + destination: ChainName, + delayMs = 5000, + maxAttempts = 60, + ): Promise { + const destAdapter = new StarknetCoreAdapter( + destination, + this.multiProvider, + { mailbox: this.addresses.mailbox }, + ); + + const mailboxContract = getStarknetMailboxContract( + destAdapter.addresses.mailbox, + destAdapter.getProvider(), + ); + + await pollAsync( + async () => { + const isDelivered = await mailboxContract.call('delivered', [ + messageId, + ]); + + if (!isDelivered) { + throw new Error( + `Message ${messageId} not yet delivered on ${destination}`, + ); + } + + this.logger.debug( + `Message ${messageId} confirmed delivered on ${destination}`, + ); + + return isDelivered; + }, + delayMs, + maxAttempts, + ); + + return true; + } +} diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index 131c7b4b44b..2ce28562d41 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -60,6 +60,7 @@ export { export { CosmWasmCoreAdapter } from './core/adapters/CosmWasmCoreAdapter.js'; export { EvmCoreAdapter } from './core/adapters/EvmCoreAdapter.js'; export { SealevelCoreAdapter } from './core/adapters/SealevelCoreAdapter.js'; +export { StarknetCoreAdapter } from './core/adapters/StarknetCoreAdapter.js'; export { ICoreAdapter } from './core/adapters/types.js'; export { CoreAddresses, @@ -365,6 +366,10 @@ export { SolanaWeb3Provider, SolanaWeb3Transaction, SolanaWeb3TransactionReceipt, + StarknetJsContract, + StarknetJsProvider, + StarknetJsTransaction, + StarknetJsTransactionReceipt, TypedContract, TypedProvider, TypedTransaction, @@ -705,3 +710,12 @@ export { CCIPContractCache, } from './ccip/utils.js'; export { HyperlaneCCIPDeployer } from './ccip/HyperlaneCCIPDeployer.js'; + +export { + StarknetContractName, + getStarknetContract, + getStarknetHypERC20Contract, + getStarknetHypERC20CollateralContract, + getStarknetMailboxContract, + getStarknetEtherContract, +} from './utils/starknet.js'; diff --git a/typescript/sdk/src/providers/MultiProtocolProvider.ts b/typescript/sdk/src/providers/MultiProtocolProvider.ts index ffcecd9fe59..1e30cdf4714 100644 --- a/typescript/sdk/src/providers/MultiProtocolProvider.ts +++ b/typescript/sdk/src/providers/MultiProtocolProvider.ts @@ -25,6 +25,7 @@ import { ProviderMap, ProviderType, SolanaWeb3Provider, + StarknetJsProvider, TypedProvider, TypedTransaction, ViemProvider, @@ -215,6 +216,15 @@ export class MultiProtocolProvider< ); } + getStarknetProvider( + chainNameOrId: ChainNameOrId, + ): StarknetJsProvider['provider'] { + return this.getSpecificProvider( + chainNameOrId, + ProviderType.Starknet, + ); + } + setProvider( chainNameOrId: ChainNameOrId, provider: TypedProvider, diff --git a/typescript/sdk/src/providers/MultiProvider.ts b/typescript/sdk/src/providers/MultiProvider.ts index 7b5f923061f..9f9f8b0fe26 100644 --- a/typescript/sdk/src/providers/MultiProvider.ts +++ b/typescript/sdk/src/providers/MultiProvider.ts @@ -316,7 +316,7 @@ export class MultiProvider extends ChainMetadataManager { // setup contract factory const overrides = this.getTransactionOverrides(chainNameOrId); const signer = this.getSigner(chainNameOrId); - const contractFactory = await factory.connect(signer); + const contractFactory = factory.connect(signer); // estimate gas const deployTx = contractFactory.getDeployTransaction(...params); diff --git a/typescript/sdk/src/providers/ProviderType.ts b/typescript/sdk/src/providers/ProviderType.ts index 590d8a2e68f..80012225254 100644 --- a/typescript/sdk/src/providers/ProviderType.ts +++ b/typescript/sdk/src/providers/ProviderType.ts @@ -19,8 +19,7 @@ import { Contract as StarknetContract, Invocation as StarknetInvocation, Provider as StarknetProvider, - ReceiptTx as StarknetReceiptTx, - TransactionReceipt as StarknetTxReceipt, + GetTransactionReceiptResponse as StarknetTxReceipt, } from 'starknet'; import type { GetContractReturnType, @@ -356,9 +355,9 @@ export interface CosmJsNativeTransactionReceipt } export interface StarknetJsTransactionReceipt - extends TypedTransactionReceiptBase { + extends TypedTransactionReceiptBase { type: ProviderType.Starknet; - receipt: StarknetTxReceipt | StarknetReceiptTx; + receipt: StarknetTxReceipt; } export interface ZKSyncTransactionReceipt diff --git a/typescript/sdk/src/providers/rpcHealthTest.ts b/typescript/sdk/src/providers/rpcHealthTest.ts index 57b8e4579dc..459f918d9c3 100644 --- a/typescript/sdk/src/providers/rpcHealthTest.ts +++ b/typescript/sdk/src/providers/rpcHealthTest.ts @@ -10,6 +10,7 @@ import { EthersV5Provider, ProviderType, SolanaWeb3Provider, + StarknetJsProvider, } from './ProviderType.js'; import { protocolToDefaultProviderBuilder } from './providerBuilders.js'; @@ -30,6 +31,8 @@ export async function isRpcHealthy( provider.type === ProviderType.CosmJsNative ) return isCosmJsProviderHealthy(provider.provider, metadata); + else if (provider.type === ProviderType.Starknet) + return isStarknetJsProviderHealthy(provider.provider, metadata); else throw new Error( `Unsupported provider type ${provider.type}, new health check required`, @@ -88,3 +91,13 @@ export async function isCosmJsProviderHealthy( rootLogger.debug(`Block number is okay for ${metadata.name}`); return true; } + +export async function isStarknetJsProviderHealthy( + provider: StarknetJsProvider['provider'], + metadata: ChainMetadata, +): Promise { + const blockNumber = await provider.getBlockNumber(); + if (!blockNumber || blockNumber < 0) return false; + rootLogger.debug(`Block number is okay for ${metadata.name}`); + return true; +} diff --git a/typescript/sdk/src/providers/transactionFeeEstimators.ts b/typescript/sdk/src/providers/transactionFeeEstimators.ts index 56fa4885e74..2a96683142d 100644 --- a/typescript/sdk/src/providers/transactionFeeEstimators.ts +++ b/typescript/sdk/src/providers/transactionFeeEstimators.ts @@ -22,6 +22,8 @@ import { ProviderType, SolanaWeb3Provider, SolanaWeb3Transaction, + StarknetJsProvider, + StarknetJsTransaction, TypedProvider, TypedTransaction, ViemProvider, @@ -326,9 +328,28 @@ export function estimateTransactionFee({ sender, senderPubKey, }); + } else if ( + transaction.type === ProviderType.Starknet && + provider.type === ProviderType.Starknet + ) { + return estimateTransactionFeeStarknet({ transaction, provider, sender }); } else { throw new Error( `Unsupported transaction type ${transaction.type} or provider type ${provider.type} for gas estimation`, ); } } + +// Starknet does not support gas estimation without starknet account +// TODO: Figure out a way to inject starknet account +export async function estimateTransactionFeeStarknet({ + transaction: _transaction, + provider: _provider, + sender: _sender, +}: { + transaction: StarknetJsTransaction; + provider: StarknetJsProvider; + sender: Address; +}): Promise { + return { gasUnits: 0, gasPrice: 0, fee: 0 }; +} diff --git a/typescript/sdk/src/token/Token.ts b/typescript/sdk/src/token/Token.ts index a09be1b85aa..43edec5e820 100644 --- a/typescript/sdk/src/token/Token.ts +++ b/typescript/sdk/src/token/Token.ts @@ -65,6 +65,11 @@ import { SealevelNativeTokenAdapter, SealevelTokenAdapter, } from './adapters/SealevelTokenAdapter.js'; +import { + StarknetHypCollateralAdapter, + StarknetHypNativeAdapter, + StarknetHypSyntheticAdapter, +} from './adapters/StarknetTokenAdapter.js'; import { PROTOCOL_TO_DEFAULT_NATIVE_TOKEN } from './nativeTokenMetadata.js'; // Declaring the interface in addition to class allows @@ -296,6 +301,18 @@ export class Token implements IToken { return new CosmNativeHypSyntheticAdapter(chainName, multiProvider, { token: addressOrDenom, }); + } else if (standard === TokenStandard.StarknetHypNative) { + return new StarknetHypNativeAdapter(chainName, multiProvider, { + warpRouter: addressOrDenom, + }); + } else if (standard === TokenStandard.StarknetHypSynthetic) { + return new StarknetHypSyntheticAdapter(chainName, multiProvider, { + warpRouter: addressOrDenom, + }); + } else if (standard === TokenStandard.StarknetHypCollateral) { + return new StarknetHypCollateralAdapter(chainName, multiProvider, { + warpRouter: addressOrDenom, + }); } else { throw new Error(`No hyp adapter found for token standard: ${standard}`); } diff --git a/typescript/sdk/src/token/adapters/StarknetTokenAdapter.ts b/typescript/sdk/src/token/adapters/StarknetTokenAdapter.ts new file mode 100644 index 00000000000..177a96351a6 --- /dev/null +++ b/typescript/sdk/src/token/adapters/StarknetTokenAdapter.ts @@ -0,0 +1,293 @@ +import { BigNumber } from 'ethers'; +import { CairoOption, CairoOptionVariant, Call, Contract, num } from 'starknet'; + +import { + Address, + Domain, + Numberish, + ProtocolType, + assert, +} from '@hyperlane-xyz/utils'; + +import { BaseStarknetAdapter } from '../../app/MultiProtocolApp.js'; +import { MultiProtocolProvider } from '../../providers/MultiProtocolProvider.js'; +import { ChainName } from '../../types.js'; +import { + getStarknetEtherContract, + getStarknetHypERC20CollateralContract, + getStarknetHypERC20Contract, +} from '../../utils/starknet.js'; +import { PROTOCOL_TO_DEFAULT_NATIVE_TOKEN } from '../nativeTokenMetadata.js'; +import { TokenMetadata } from '../types.js'; + +import { + IHypTokenAdapter, + InterchainGasQuote, + TransferParams, + TransferRemoteParams, +} from './ITokenAdapter.js'; + +export class StarknetHypSyntheticAdapter + extends BaseStarknetAdapter + implements IHypTokenAdapter +{ + public readonly contract: Contract; + + constructor( + public readonly chainName: ChainName, + public readonly multiProvider: MultiProtocolProvider, + public readonly addresses: { warpRouter: Address }, + ) { + super(chainName, multiProvider, addresses); + this.contract = getStarknetHypERC20Contract( + addresses.warpRouter, + multiProvider.getStarknetProvider(chainName), + ); + } + + async getBalance(address: Address): Promise { + return this.contract.balanceOf(address); + } + + async getMetadata(_isNft?: boolean): Promise { + const [decimals, symbol, name] = await Promise.all([ + this.contract.decimals(), + this.contract.symbol(), + this.contract.name(), + ]); + return { decimals, symbol, name }; + } + + async isApproveRequired( + owner: Address, + spender: Address, + weiAmountOrId: Numberish, + ): Promise { + const allowance = await this.contract.allowance(owner, spender); + return BigNumber.from(allowance.toString()).lt( + BigNumber.from(weiAmountOrId), + ); + } + + async isRevokeApprovalRequired( + _owner: Address, + _spender: Address, + ): Promise { + return false; + } + + async populateApproveTx({ + weiAmountOrId, + recipient, + }: TransferParams): Promise { + return this.contract.populateTransaction.approve(recipient, weiAmountOrId); + } + + async populateTransferTx({ + weiAmountOrId, + recipient, + }: TransferParams): Promise { + return this.contract.populateTransaction.transfer(recipient, weiAmountOrId); + } + + async getTotalSupply(): Promise { + return undefined; + } + + async quoteTransferRemoteGas( + _destination: Domain, + ): Promise { + return { amount: 0n }; + } + + async populateTransferRemoteTx({ + weiAmountOrId, + destination, + recipient, + interchainGas, + }: TransferRemoteParams): Promise { + const nonOption = new CairoOption(CairoOptionVariant.None); + const transferTx = this.contract.populateTransaction.transfer_remote( + destination, + recipient, + BigInt(weiAmountOrId.toString()), + 0n, + nonOption, + nonOption, + ); + + return { + ...transferTx, + value: interchainGas?.amount + ? BigNumber.from(interchainGas.amount) + : BigNumber.from(0), + }; + } + + async getMinimumTransferAmount(_recipient: Address): Promise { + return 0n; + } + + async getDomains(): Promise { + return this.contract.domains(); + } + + async getRouterAddress(domain: Domain): Promise { + const routerAddresses = await this.contract.routers(domain); + return Buffer.from(routerAddresses); + } + + async getAllRouters(): Promise> { + const domains = await this.getDomains(); + const routers: Buffer[] = await Promise.all( + domains.map((d) => this.getRouterAddress(d)), + ); + return domains.map((d, i) => ({ domain: d, address: routers[i] })); + } + + async getBridgedSupply(): Promise { + return undefined; + } +} + +export class StarknetHypCollateralAdapter extends StarknetHypSyntheticAdapter { + public readonly collateralContract: Contract; + protected wrappedTokenAddress?: Address; + + constructor( + chainName: ChainName, + multiProvider: MultiProtocolProvider, + addresses: { warpRouter: Address }, + ) { + super(chainName, multiProvider, addresses); + this.collateralContract = getStarknetHypERC20CollateralContract( + addresses.warpRouter, + multiProvider.getStarknetProvider(chainName), + ); + } + + protected async getWrappedTokenAddress(): Promise
{ + if (!this.wrappedTokenAddress) { + this.wrappedTokenAddress = num.toHex64( + await this.collateralContract.get_wrapped_token(), + ); + } + return this.wrappedTokenAddress!; + } + + protected async getWrappedTokenAdapter(): Promise { + return new StarknetHypSyntheticAdapter(this.chainName, this.multiProvider, { + warpRouter: await this.getWrappedTokenAddress(), + }); + } + + async getBalance(address: Address): Promise { + const adapter = await this.getWrappedTokenAdapter(); + return adapter.getBalance(address); + } + + override getBridgedSupply(): Promise { + return this.getBalance(this.addresses.warpRouter); + } + + override async getMetadata(isNft?: boolean): Promise { + const adapter = await this.getWrappedTokenAdapter(); + return adapter.getMetadata(isNft); + } + + override async isApproveRequired( + owner: Address, + spender: Address, + weiAmountOrId: Numberish, + ): Promise { + const adapter = await this.getWrappedTokenAdapter(); + return adapter.isApproveRequired(owner, spender, weiAmountOrId); + } + + override async populateApproveTx(params: TransferParams): Promise { + const adapter = await this.getWrappedTokenAdapter(); + return adapter.populateApproveTx(params); + } + + override async populateTransferTx(params: TransferParams): Promise { + const adapter = await this.getWrappedTokenAdapter(); + return adapter.populateTransferTx(params); + } +} + +export class StarknetHypNativeAdapter extends StarknetHypSyntheticAdapter { + public readonly collateralContract: Contract; + public readonly nativeContract: Contract; + + constructor( + chainName: ChainName, + multiProvider: MultiProtocolProvider, + addresses: { warpRouter: Address }, + ) { + super(chainName, multiProvider, addresses); + this.collateralContract = getStarknetHypERC20CollateralContract( + addresses.warpRouter, + multiProvider.getStarknetProvider(chainName), + ); + const nativeAddress = + multiProvider.getChainMetadata(chainName)?.nativeToken?.denom; + const tokenAddress = + nativeAddress ?? + PROTOCOL_TO_DEFAULT_NATIVE_TOKEN[ProtocolType.Starknet]!.denom; + assert(tokenAddress, `Native address not found for chain ${chainName}`); + this.nativeContract = getStarknetEtherContract( + tokenAddress, + multiProvider.getStarknetProvider(chainName), + ); + } + + async getBalance(address: Address): Promise { + return this.nativeContract.balanceOf(address); + } + + async isApproveRequired( + owner: Address, + spender: Address, + weiAmountOrId: Numberish, + ): Promise { + const allowance = await this.nativeContract.allowance(owner, spender); + return BigNumber.from(allowance.toString()).lt( + BigNumber.from(weiAmountOrId), + ); + } + + async populateApproveTx({ + weiAmountOrId, + recipient, + }: TransferParams): Promise { + return this.nativeContract.populateTransaction.approve( + recipient, + weiAmountOrId, + ); + } + + async populateTransferRemoteTx({ + weiAmountOrId, + destination, + recipient, + interchainGas, + }: TransferRemoteParams): Promise { + const nonOption = new CairoOption(CairoOptionVariant.None); + const transferTx = + this.collateralContract.populateTransaction.transfer_remote( + destination, + recipient, + BigInt(weiAmountOrId.toString()), + BigInt(weiAmountOrId.toString()), + nonOption, + nonOption, + ); + + return { + ...transferTx, + value: interchainGas?.amount + ? BigNumber.from(interchainGas.amount) + : BigNumber.from(0), + }; + } +} diff --git a/typescript/sdk/src/utils/starknet.ts b/typescript/sdk/src/utils/starknet.ts new file mode 100644 index 00000000000..637e615accb --- /dev/null +++ b/typescript/sdk/src/utils/starknet.ts @@ -0,0 +1,129 @@ +import { utils } from 'ethers'; +import { + AccountInterface, + Contract, + ParsedEvent, + ParsedEvents, + ParsedStruct, + ProviderInterface, +} from 'starknet'; + +import { + ContractType, + getCompiledContract, +} from '@hyperlane-xyz/starknet-core'; + +import { DispatchedMessage } from '../core/types.js'; + +export enum StarknetContractName { + MAILBOX = 'mailbox', + HYP_ERC20 = 'HypErc20', + HYP_ERC20_COLLATERAL = 'HypErc20Collateral', + HYP_NATIVE = 'HypNative', + ETHER = 'Ether', + MERKLE_TREE_HOOK = 'merkle_tree_hook', + NOOP_ISM = 'noop_ism', + HOOK = 'hook', + PROTOCOL_FEE = 'protocol_fee', + VALIDATOR_ANNOUNCE = 'validator_announce', + MESSAGE_RECIPIENT = 'message_recipient', + DOMAIN_ROUTING_HOOK = 'domain_routing_hook', + FALLBACK_DOMAIN_ROUTING_HOOK = 'fallback_domain_routing_hook', + STATIC_AGGREGATION_HOOK = 'static_aggregation_hook', +} + +/** + * Creates a Starknet contract instance with the given parameters + */ +export function getStarknetContract( + contractName: string, + address: string, + providerOrAccount?: ProviderInterface | AccountInterface, + contractType: ContractType = ContractType.CONTRACT, +): Contract { + const { abi } = getCompiledContract(contractName, contractType); + return new Contract(abi, address, providerOrAccount); +} + +export function getStarknetMailboxContract( + address: string, + providerOrAccount?: ProviderInterface | AccountInterface, +): Contract { + return getStarknetContract( + StarknetContractName.MAILBOX, + address, + providerOrAccount, + ); +} + +export function getStarknetHypERC20Contract( + address: string, + providerOrAccount?: ProviderInterface | AccountInterface, +): Contract { + return getStarknetContract( + StarknetContractName.HYP_ERC20, + address, + providerOrAccount, + ContractType.TOKEN, + ); +} + +export function getStarknetHypERC20CollateralContract( + address: string, + providerOrAccount?: ProviderInterface | AccountInterface, +): Contract { + return getStarknetContract( + StarknetContractName.HYP_ERC20_COLLATERAL, + address, + providerOrAccount, + ContractType.TOKEN, + ); +} + +export function getStarknetEtherContract( + address: string, + providerOrAccount?: ProviderInterface | AccountInterface, +): Contract { + return getStarknetContract( + StarknetContractName.ETHER, + address, + providerOrAccount, + ContractType.TOKEN, + ); +} + +const DISPATCH_EVENT = 'contracts::mailbox::mailbox::Dispatch'; +const DISPATCH_ID_EVENT = 'contracts::mailbox::mailbox::DispatchId'; + +export function parseStarknetDispatchEvents( + parsedEvents: ParsedEvents, + chainNameResolver: (domain: number) => string | undefined, +): DispatchedMessage[] { + return parsedEvents + .filter((event: ParsedEvent) => DISPATCH_EVENT in event) + .map((dispatchEvent: ParsedEvent) => { + const message = dispatchEvent[DISPATCH_EVENT].message as ParsedStruct; + const originChain = chainNameResolver(Number(message.origin)); + const destinationChain = chainNameResolver(Number(message.destination)); + + return { + parsed: { + ...message, + originChain, + destinationChain, + }, + id: parseStarknetDispatchIdEvents(parsedEvents)[0], + message: message.raw, + } as DispatchedMessage; + }); +} + +export function parseStarknetDispatchIdEvents( + parsedEvents: ParsedEvents, +): string[] { + return parsedEvents + .filter((event: ParsedEvent) => DISPATCH_ID_EVENT in event) + .map((dispatchEvent: ParsedEvent) => + utils.hexlify(dispatchEvent[DISPATCH_ID_EVENT].id as bigint), + ); +} diff --git a/typescript/sdk/src/warp/WarpCore.ts b/typescript/sdk/src/warp/WarpCore.ts index 6617e6d89a2..208236cd358 100644 --- a/typescript/sdk/src/warp/WarpCore.ts +++ b/typescript/sdk/src/warp/WarpCore.ts @@ -227,6 +227,11 @@ export class WarpCore { interchainFee, }); + // Starknet does not support gas estimation without starknet account + if (originToken.protocol === ProtocolType.Starknet) { + return { gasUnits: 0n, gasPrice: 0n, fee: 0n }; + } + // Typically the transfers require a single transaction if (txs.length === 1) { try { diff --git a/typescript/sdk/src/warp/test-warp-core-config.yaml b/typescript/sdk/src/warp/test-warp-core-config.yaml index 3b1944f5e6d..4c551ebbd79 100644 --- a/typescript/sdk/src/warp/test-warp-core-config.yaml +++ b/typescript/sdk/src/warp/test-warp-core-config.yaml @@ -124,6 +124,14 @@ tokens: symbol: atom name: atom addressOrDenom: atom + - chainName: starknetdevnet + standard: StarknetHypSynthetic + decimals: 18 + symbol: ETH + name: Ether on starknet + addressOrDenom: '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + connections: + - { token: ethereum|test1|0x1234567890123456789012345678901234567890 } options: interchainFeeConstants: diff --git a/yarn.lock b/yarn.lock index c89e4f0a17e..54cc9492eae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7932,6 +7932,7 @@ __metadata: "@eslint/js": "npm:^9.15.0" "@hyperlane-xyz/core": "npm:7.1.4" "@hyperlane-xyz/cosmos-sdk": "npm:12.5.0" + "@hyperlane-xyz/starknet-core": "npm:1.0.0" "@hyperlane-xyz/utils": "npm:12.5.0" "@nomiclabs/hardhat-ethers": "npm:^2.2.3" "@nomiclabs/hardhat-waffle": "npm:^2.0.6" @@ -7977,7 +7978,7 @@ __metadata: languageName: unknown linkType: soft -"@hyperlane-xyz/starknet-core@workspace:starknet": +"@hyperlane-xyz/starknet-core@npm:1.0.0, @hyperlane-xyz/starknet-core@workspace:starknet": version: 0.0.0-use.local resolution: "@hyperlane-xyz/starknet-core@workspace:starknet" dependencies: