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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ Create a `config.json` file with the following structure:
"bech32Prefix": "init",
"chainId": "chain-2",
"gasPrice": "0umin",
"restUri": "https://rest.chain-2.com",
"restUri": [
"https://rest.chain-2.com",
"https://rest.chain-2.com/fallback"
],
"rpcUri": ["https://rpc.chain-2.com", "https://rpc.chain-2.com/fallback"],
"wallets": [
{
Expand Down Expand Up @@ -129,7 +132,7 @@ You can configure chains in two ways:
- `CHAIN_<index>_CHAIN_ID`: Chain ID for the chain at index `<index>`
- `CHAIN_<index>_BECH32_PREFIX`: Bech32 prefix for the chain
- `CHAIN_<index>_GAS_PRICE`: Gas price for the chain
- `CHAIN_<index>_REST_URI`: REST URI for the chain
- `CHAIN_<index>_REST_URI`: REST URI for the chain (can be a single URI string or JSON array of URI strings)
- `CHAIN_<index>_RPC_URI`: RPC URI for the chain (can be a single URI string or array of URI strings)
- `CHAIN_<index>_FEE_FILTER`: JSON string containing the fee filter configuration

Expand Down Expand Up @@ -182,7 +185,7 @@ export CHAIN_0_FEE_FILTER='{"recvFee":[{"denom":"gas","amount":100}],"timeoutFee
export CHAIN_1_CHAIN_ID=chain-2
export CHAIN_1_BECH32_PREFIX=init
export CHAIN_1_GAS_PRICE=0umin
export CHAIN_1_REST_URI=https://rest.chain-2.com
export CHAIN_1_REST_URI='["https://rest.chain-2.com", "https://rest-fallback.chain-2.com"]' # JSON array of URI strings for fallback
export CHAIN_1_RPC_URI='["https://rpc.chain-2.com", "https://rpc-fallback.chain-2.com"]' # JSON array of URI strings for fallback

# Chain 2 wallet 1 configuration
Expand Down
5 changes: 4 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
"bech32Prefix": "init",
"chainId": "chain-2",
"gasPrice": "0umin",
"restUri": "https://rest.chain-2.com",
"restUri": [
"https://rest.chain-2.com",
"https://rest.chain-2.com/fallback"
],
"rpcUri": "https://rpc.chain-2.com",
"wallets": [
{
Expand Down
14 changes: 12 additions & 2 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,18 @@
"description": "gas price in format 0.1denom"
},
"restUri": {
"type": "string",
"description": "cosmos rest api uri"
"description": "cosmos rest api uri",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"rpcUri": {
"description": "cosmos rest rpc uri",
Expand Down
37 changes: 37 additions & 0 deletions src/lib/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,43 @@ describe('Configuration Loading', () => {
])
})

it('should parse REST URI as array when formatted as JSON array', () => {
process.env.CHAIN_1_CHAIN_ID = 'test-chain-1'
process.env.CHAIN_1_BECH32_PREFIX = 'test'
process.env.CHAIN_1_GAS_PRICE = '0.01'
process.env.CHAIN_1_REST_URI =
'["http://localhost:1317", "http://localhost:1318"]'
process.env.CHAIN_1_RPC_URI = 'http://localhost:26657'

// Setup wallet env vars
process.env.CHAIN_1_WALLET_1_KEY_TYPE = 'raw'
process.env.CHAIN_1_WALLET_1_KEY_PRIVATE_KEY = 'testkey'

const config = loadEnvConfig()

expect(config.chains?.[0].restUri).toEqual([
'http://localhost:1317',
'http://localhost:1318',
])
})

it.each(['["http://localhost:1317",]', '[]', '[""]', '[123]'])(
'should reject invalid URI arrays: %s',
(uriArray) => {
process.env.CHAIN_1_CHAIN_ID = 'test-chain-1'
process.env.CHAIN_1_BECH32_PREFIX = 'test'
process.env.CHAIN_1_GAS_PRICE = '0.01'
process.env.CHAIN_1_REST_URI = uriArray
process.env.CHAIN_1_RPC_URI = 'http://localhost:26657'

// Setup wallet env vars
process.env.CHAIN_1_WALLET_1_KEY_TYPE = 'raw'
process.env.CHAIN_1_WALLET_1_KEY_PRIVATE_KEY = 'testkey'

expect(() => loadEnvConfig()).toThrow(/URI array/)
}
)

it('should parse fee filter from env vars', () => {
const feeFilter: PacketFee = {
recvFee: [{ denom: 'token', amount: 100 }],
Expand Down
40 changes: 32 additions & 8 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ export const safeJsonParse = <T>(json: string, defaultValue: T): T => {
}
}

const parseUriConfig = (rawUri: string): string | string[] => {
const trimmedUri = rawUri.trim()

if (trimmedUri.startsWith('[') && trimmedUri.endsWith(']')) {
let parsed: unknown
try {
parsed = JSON.parse(trimmedUri)
} catch (err) {
throw new Error(`Invalid URI array JSON: ${err}`)
}

if (
!Array.isArray(parsed) ||
parsed.length === 0 ||
parsed.some((uri) => typeof uri !== 'string' || uri.trim() === '')
) {
throw new Error('URI array must contain at least one non-empty string')
}

return parsed.map((uri) => uri.trim())
}

return trimmedUri
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// load configuration from environment variables
export const loadEnvConfig = (): Partial<Config> => {
const envConfig: Partial<Config> = {}
Expand Down Expand Up @@ -79,16 +104,15 @@ export const loadEnvConfig = (): Partial<Config> => {
chain.bech32Prefix = env[`CHAIN_${index}_BECH32_PREFIX`]
if (env[`CHAIN_${index}_GAS_PRICE`])
chain.gasPrice = env[`CHAIN_${index}_GAS_PRICE`]
if (env[`CHAIN_${index}_REST_URI`])
chain.restUri = env[`CHAIN_${index}_REST_URI`]

const rawRestUri = env[`CHAIN_${index}_REST_URI`]?.trim()
if (rawRestUri) {
chain.restUri = parseUriConfig(rawRestUri)
}

const rawRpcUri = env[`CHAIN_${index}_RPC_URI`]?.trim()
if (rawRpcUri) {
if (rawRpcUri.startsWith('[') && rawRpcUri.endsWith(']')) {
chain.rpcUri = safeJsonParse<string[]>(rawRpcUri, [rawRpcUri])
} else {
chain.rpcUri = rawRpcUri
}
chain.rpcUri = parseUriConfig(rawRpcUri)
}

// fee filter
Expand Down Expand Up @@ -331,7 +355,7 @@ interface ChainConfig {
bech32Prefix: string
chainId: string
gasPrice: string
restUri: string
restUri: string | string[]
rpcUri: string | string[]
wallets: WalletConfig[]
feeFilter?: PacketFee
Expand Down
84 changes: 84 additions & 0 deletions src/lib/restClient.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import nock from 'nock'
import { RESTClient } from './restClient'
import { logger } from './logger'

const mockRestUris = [
'http://doi-rest-1.com',
'http://moro-rest-2.com',
'http://rene-rest-3.com',
]

describe('RESTClient', () => {
afterEach(() => {
nock.cleanAll()
jest.restoreAllMocks()
})

it('should use the first endpoint successfully', async () => {
nock(mockRestUris[0]).get('/cosmos/base/node/v1beta1/config').reply(200, {
minimum_gas_price: '0.01uinit',
})

const client = new RESTClient(mockRestUris)
const result = await client.apiRequester.get<{ minimum_gas_price: string }>(
'/cosmos/base/node/v1beta1/config'
)

expect(result.minimum_gas_price).toBe('0.01uinit')
})

it('should fallback to the next endpoint if the first one fails', async () => {
nock(mockRestUris[0]).get('/cosmos/base/node/v1beta1/config').reply(500)
nock(mockRestUris[1]).get('/cosmos/base/node/v1beta1/config').reply(200, {
minimum_gas_price: '0.02uinit',
})

const loggerSpy = jest.spyOn(logger, 'error')
const client = new RESTClient(mockRestUris)
const result = await client.apiRequester.get<{ minimum_gas_price: string }>(
'/cosmos/base/node/v1beta1/config'
)

expect(result.minimum_gas_price).toBe('0.02uinit')
expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining(
`[REST] Failed to request to ${mockRestUris[0]} - /cosmos/base/node/v1beta1/config`
)
)
})

it('should not fallback on client errors', async () => {
nock(mockRestUris[0]).get('/cosmos/base/node/v1beta1/config').reply(404)
const fallback = nock(mockRestUris[1])
.get('/cosmos/base/node/v1beta1/config')
.reply(200, {
minimum_gas_price: '0.02uinit',
})

const client = new RESTClient(mockRestUris)

await expect(
client.apiRequester.get('/cosmos/base/node/v1beta1/config')
).rejects.toThrow()
expect(fallback.isDone()).toBe(false)
})

it('should prefer the last successful endpoint for later requests', async () => {
nock(mockRestUris[0]).get('/cosmos/base/node/v1beta1/config').reply(500)
nock(mockRestUris[1]).get('/cosmos/base/node/v1beta1/config').reply(200, {
minimum_gas_price: '0.02uinit',
})
nock(mockRestUris[1]).get('/cosmos/base/node/v1beta1/config').reply(200, {
minimum_gas_price: '0.03uinit',
})

const client = new RESTClient(mockRestUris)

await client.apiRequester.get('/cosmos/base/node/v1beta1/config')
const result = await client.apiRequester.get<{ minimum_gas_price: string }>(
'/cosmos/base/node/v1beta1/config'
)

expect(result.minimum_gas_price).toBe('0.03uinit')
})
})
Loading
Loading