Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
7 changes: 7 additions & 0 deletions .changeset/fuzzy-ducks-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@xchainjs/xchain-client': patch
'@xchainjs/xchain-utxo': patch
'@xchainjs/xchain-evm': patch
---

Updated get fee rates to observe Mayachain as well
135 changes: 130 additions & 5 deletions packages/xchain-client/src/BaseXChainClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const MAINNET_THORNODE_API_BASE = 'https://thornode.ninerealms.com/thorchain'
const STAGENET_THORNODE_API_BASE = 'https://stagenet-thornode.ninerealms.com/thorchain'
const TESTNET_THORNODE_API_BASE = 'https://testnet.thornode.thorchain.info/thorchain'

const MAINNET_MAYANODE_API_BASE = 'https://mayanode.mayachain.info/mayachain'
const STAGENET_MAYANODE_API_BASE = 'https://stagenet.mayanode.mayachain.info/mayachain'
const TESTNET_MAYANODE_API_BASE = 'https://testnet.mayanode.mayachain.info/mayachain'

export abstract class BaseXChainClient implements XChainClient {
protected chain: Chain // The blockchain chain identifier
protected network: Network // The network (e.g., Mainnet, Testnet, Stagenet)
Expand Down Expand Up @@ -87,16 +91,118 @@ export abstract class BaseXChainClient implements XChainClient {

/**
* Get the fee rate from the Thorchain API.
* @returns {Promise<FeeRate>} The fee rate
* @returns {Promise<FeeRate>} The fee rate in the expected unit for each chain type
*/
protected async getFeeRateFromThorchain(): Promise<FeeRate> {
const respData = await this.thornodeAPIGet('/inbound_addresses')
if (!Array.isArray(respData)) throw new Error('bad response from Thornode API')
const chainData: { chain: Chain; gas_rate: string } = respData.find(
(elem) => elem.chain === this.chain && typeof elem.gas_rate === 'string',
)

const chainData: {
chain: Chain
gas_rate: string
gas_rate_units?: string
} = respData.find((elem) => elem.chain === this.chain && typeof elem.gas_rate === 'string')

if (!chainData) throw new Error(`Thornode API /inbound_addresses does not contain fees for ${this.chain}`)
return Number(chainData.gas_rate)

const gasRate = Number(chainData.gas_rate)
const gasRateUnits = chainData.gas_rate_units || ''

// Convert gas_rate based on gas_rate_units to the expected unit for each chain type
// EVM clients expect values in gwei and will multiply by 10^9 to get wei
// UTXO clients expect satoshis per byte directly

// First, try unit-based conversion for common patterns
switch (gasRateUnits) {
case 'gwei':
return gasRate // Already in gwei for EVM chains
case 'mwei':
return gasRate / 1e3 // Convert mwei to gwei (1 mwei = 0.001 gwei)
case 'centigwei':
return gasRate / 100 // Convert centigwei to gwei
case 'satsperbyte':
return gasRate // UTXO chains use this directly
case 'drop':
return gasRate // XRP uses drops
case 'uatom':
return gasRate // Cosmos chains use micro units
default:
// Fall back to chain-specific logic for nano units and special cases
break
}

// Chain-specific handling for special cases
switch (this.chain) {
case 'AVAX':
// nAVAX = nano AVAX = 10^-9 AVAX = gwei equivalent
// Already in the right unit for EVM client
if (gasRateUnits !== 'nAVAX') {
console.warn(`Unexpected gas_rate_units for AVAX: ${gasRateUnits}`)
}
return gasRate

default:
// For nano-prefixed units (nETH, nBSC, etc.), treat as gwei equivalent
if (gasRateUnits.startsWith('n') && gasRateUnits.length > 1) {
return gasRate // nano units = gwei equivalent for EVM chains
}
// For micro-prefixed units (uatom, etc.), return as-is for Cosmos chains
if (gasRateUnits.startsWith('u') && gasRateUnits.length > 1) {
return gasRate // micro units for Cosmos chains
}
break
}

// If we reach here, log a warning but return the raw value
console.warn(`Unknown gas_rate_units "${gasRateUnits}" for chain ${this.chain}. Using raw value.`)
return gasRate
}

/**
* Get the fee rate from the Mayachain API.
* @returns {Promise<FeeRate>} The fee rate in the expected unit for each chain type
*/
protected async getFeeRateFromMayachain(): Promise<FeeRate> {
const respData = await this.mayanodeAPIGet('/inbound_addresses')
if (!Array.isArray(respData)) throw new Error('bad response from Mayanode API')

const chainData: {
chain: Chain
gas_rate: string
gas_rate_units?: string
} = respData.find((elem) => elem.chain === this.chain && typeof elem.gas_rate === 'string')

if (!chainData) throw new Error(`Mayanode API /inbound_addresses does not contain fees for ${this.chain}`)

const gasRate = Number(chainData.gas_rate)
const gasRateUnits = chainData.gas_rate_units || ''

// Log for debugging
if (gasRateUnits) {
console.debug(`Mayachain gas_rate for ${this.chain}: ${gasRate} ${gasRateUnits}`)
}

// Prefer unit-based conversion (parity with Thornode logic)
switch (gasRateUnits) {
case 'gwei':
return gasRate
case 'mwei':
return gasRate / 1e3
case 'centigwei':
return gasRate / 100
case 'satsperbyte':
return gasRate
case 'drop':
return gasRate
case 'uatom':
return gasRate
default:
// Chain-specific fallbacks for nano/micro prefixes
if (gasRateUnits.startsWith('n') && gasRateUnits.length > 1) return gasRate
if (gasRateUnits.startsWith('u') && gasRateUnits.length > 1) return gasRate
console.warn(`Unknown gas_rate_units "${gasRateUnits}" for chain ${this.chain} on Mayachain. Using raw value.`)
return gasRate
}
}

/**
Expand All @@ -118,6 +224,25 @@ export abstract class BaseXChainClient implements XChainClient {
return (await axios.get(url + endpoint)).data
}

/**
* Make a GET request to the Mayachain API.
* @param {string} endpoint The API endpoint
* @returns {Promise<unknown>} The response data
*/
protected async mayanodeAPIGet(endpoint: string): Promise<unknown> {
const url = (() => {
switch (this.network) {
case Network.Mainnet:
return MAINNET_MAYANODE_API_BASE
case Network.Stagenet:
return STAGENET_MAYANODE_API_BASE
case Network.Testnet:
return TESTNET_MAYANODE_API_BASE
}
})()
return (await axios.get(url + endpoint)).data
}

/**
* Set or update the mnemonic phrase.
* @param {string} phrase The new mnemonic phrase
Expand Down
1 change: 1 addition & 0 deletions packages/xchain-client/src/protocols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
*/
export enum Protocol {
THORCHAIN = 1, // Protocol value for THORChain
MAYACHAIN = 2, // Protocol value for MAYAChain
}
46 changes: 46 additions & 0 deletions packages/xchain-evm/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { JsonRpcProvider } from 'ethers'
import { getCachedContract } from '../src/cache'
import erc20ABI from '../src/data/erc20.json'

describe('Contract Cache', () => {
it('should cache contracts separately for different providers', async () => {
// Create two different providers
const provider1 = new JsonRpcProvider('https://eth.llamarpc.com')
const provider2 = new JsonRpcProvider('https://goerli.infura.io/v3/test')

const contractAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // USDC address

// Get contracts from both providers
const contract1 = await getCachedContract(contractAddress, erc20ABI, provider1)
const contract2 = await getCachedContract(contractAddress, erc20ABI, provider2)

// Contracts should have different providers
expect(contract1.runner).toBe(provider1)
expect(contract2.runner).toBe(provider2)
expect(contract1).not.toBe(contract2) // Different contract instances

// Getting the same contract again should return the cached instance
const contract1Again = await getCachedContract(contractAddress, erc20ABI, provider1)
expect(contract1).toBe(contract1Again) // Same instance from cache
})

it('should cache contracts separately for different addresses on same provider', async () => {
const provider = new JsonRpcProvider('https://eth.llamarpc.com')

const address1 = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' // USDC
const address2 = '0xdAC17F958D2ee523a2206206994597C13D831ec7' // USDT

// Get contracts for different addresses
const contract1 = await getCachedContract(address1, erc20ABI, provider)
const contract2 = await getCachedContract(address2, erc20ABI, provider)

// Should be different contract instances
expect(contract1).not.toBe(contract2)
expect(contract1.target).toBe(address1)
expect(contract2.target).toBe(address2)

// Getting the same contract again should return the cached instance
const contract1Again = await getCachedContract(address1, erc20ABI, provider)
expect(contract1).toBe(contract1Again)
})
})
62 changes: 62 additions & 0 deletions packages/xchain-evm/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Contract, Provider } from 'ethers'
import BigNumber from 'bignumber.js'

// Per-provider contract cache to ensure contracts are properly isolated
// Key format: `${providerNetwork}_${chainId}_${address}`
const contractCache = new Map<string, Contract>()
const bigNumberCache = new Map<string, BigNumber>()

/**
* Generate a unique cache key for a contract that includes provider context
*/
async function getContractCacheKey(address: string, provider: Provider): Promise<string> {
try {
// Get network information from provider to create unique key
const network = await provider.getNetwork()
const chainId = network.chainId.toString()
const networkName = network.name || 'unknown'
return `${networkName}_${chainId}_${address.toLowerCase()}`
} catch {
// Fallback to a provider-specific key if network info unavailable
// Use provider instance as unique identifier
const providerIdentity = provider as any

Check failure on line 22 in packages/xchain-evm/src/cache.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
const connectionUrl = providerIdentity._request?.url ||
providerIdentity.connection?.url ||
providerIdentity._url ||
'unknown'
const hashedKey = Buffer.from(connectionUrl.toString()).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 10)
return `provider_${hashedKey}_${address.toLowerCase()}`
}
}

/**
* Get a cached Contract instance or create a new one
* Now includes provider/network isolation to prevent cross-network contract reuse
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function getCachedContract(address: string, abi: any, provider: Provider): Promise<Contract> {
const key = await getContractCacheKey(address, provider)
if (!contractCache.has(key)) {
contractCache.set(key, new Contract(address, abi, provider))
}
return contractCache.get(key)!
}

/**
* Get a cached BigNumber instance or create a new one
*/
export function getCachedBigNumber(value: string | number): BigNumber {
const stringValue = value.toString()
if (!bigNumberCache.has(stringValue)) {
bigNumberCache.set(stringValue, new BigNumber(stringValue))
}
return bigNumberCache.get(stringValue)!
}

/**
* Clear all caches (useful for testing or memory management)
*/
export function clearCaches(): void {
contractCache.clear()
bigNumberCache.clear()
}
Loading
Loading