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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

## Unreleased

- added: Robinhood Chain support (`robinhood`, EVM chain 4663), an Arbitrum Nitro L2 with ETH as its gas token. Transaction history comes from the chain's Blockscout instance, since Etherscan V2 does not cover this chain, and fees use the Arbitrum `NodeInterface` L1-component estimate. Built-in tokens are USDC, USDT, USDG, WBTC, WETH, CASHCAT and PONS.
- added: Robinhood Chain support (`robinhood`, EVM chain 4663)
- added: (EVM) Alchemy and Blockscout network adapters
- added: (Robinhood Chain) Transaction history and token balances from Alchemy, with Blockscout as fallback
- fixed: (Avalanche) Remove unreachable avascan and snowscan evmscan servers
- fixed: (HyperEVM) Tokens are detected automatically
- fixed: (EVM) Blockscout "Too many requests" replies are recognized as rate limits

## 4.90.1 (2026-08-28)

Expand Down
67 changes: 51 additions & 16 deletions src/docs/robinhood-chain.md

Large diffs are not rendered by default.

94 changes: 91 additions & 3 deletions src/ethereum/EthereumNetwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ import { normalizeAddress } from '../common/utils'
import { WEI_MULTIPLIER } from './ethereumConsts'
import { EthereumEngine } from './EthereumEngine'
import { DecoyAddressConfig, EthereumNetworkInfo } from './ethereumTypes'
import { AlchemyAdapter } from './networkAdapters/AlchemyAdapter'
import { AmberdataAdapter } from './networkAdapters/AmberdataAdapter'
import { BlockbookAdapter } from './networkAdapters/BlockbookAdapter'
import { BlockbookWsAdapter } from './networkAdapters/BlockbookWsAdapter'
import { BlockchairAdapter } from './networkAdapters/BlockchairAdapter'
import { BlockcypherAdapter } from './networkAdapters/BlockcypherAdapter'
import { EvmScanAdapter } from './networkAdapters/EvmScanAdapter'
import { BlockscoutAdapter } from './networkAdapters/BlockscoutAdapter'
import {
EvmScanAdapter,
mergeEdgeTransactions
} from './networkAdapters/EvmScanAdapter'
import { FilfoxAdapter } from './networkAdapters/FilfoxAdapter'
import {
GetTxsParams,
NetworkAdapter,
NetworkAdapterConfig,
NetworkAdapterUpdateMethod
Expand Down Expand Up @@ -57,6 +63,19 @@ type NotNull<T> = { [P in keyof T]: Exclude<T[P], null> }
export interface EdgeTransactionsBlockHeightTuple {
blockHeight: number
edgeTransactions: EdgeTransaction[]
/**
* Set by a native-asset `fetchTxs` whose rows already carry internal
* transactions, so the engine skips `fetchInternalTxs` for that pass
* instead of counting the same value twice.
*/
includesInternal?: boolean
/**
* Set when the internal-transaction source failed for this pass. The rows
* are external-only, so the engine applies just the ones it has never seen
* (or still holds as unconfirmed) and keeps the query window open, letting
* a later complete pass re-cover the range with merged rows.
*/
partial?: boolean
}

export interface EthereumNetworkUpdate {
Expand Down Expand Up @@ -342,9 +361,56 @@ export class EthereumNetwork {
}

const update = await this.check('fetchTxs', params)
if (tokenId == null) await this.mergeInternalTxs(update, params)
return this.processEthereumNetworkUpdate(update)
}

/**
* Adds internal transactions from a `fetchInternalTxs` adapter to a
* native-asset history update whose source did not include them.
*
* The merge has to happen inside one pass: the engine's addTransaction
* replaces a known transaction whose amount changed, so the external and
* internal parts of one transaction must reach it as a single row, the way
* `EvmScanAdapter` merges txlist and txlistinternal itself.
*/
private async mergeInternalTxs(
update: EthereumNetworkUpdate,
params: GetTxsParams
): Promise<void> {
const tuple = update.tokenTxs?.get(null)
if (tuple == null || tuple.includesInternal === true) return
if (this.qualifyNetworkAdapters('fetchInternalTxs').length === 0) return

let internalUpdate: EthereumNetworkUpdate
try {
internalUpdate = await this.check('fetchInternalTxs', params)
} catch (error: unknown) {
// Keep the history the primary source delivered rather than stalling
// it; the window stays open until internal transactions come back.
this.ethEngine.warn(
`fetchInternalTxs failed, applying external rows only: ${String(error)}`
)
tuple.partial = true
return
}
const internalTuple = internalUpdate.tokenTxs?.get(null)
if (internalTuple == null) return

tuple.edgeTransactions = mergeEdgeTransactions([
...tuple.edgeTransactions,
...internalTuple.edgeTransactions
])
Comment thread
j0ntz marked this conversation as resolved.
tuple.includesInternal = true
update.blockHeight = Math.max(
update.blockHeight ?? 0,
internalUpdate.blockHeight ?? 0
)
if (internalUpdate.server != null) {
update.server = `${update.server ?? 'none'} + ${internalUpdate.server}`
}
}

needsLoop = async (): Promise<void> => {
this.acquireBlockHeight().catch(error => {
console.error(error)
Expand Down Expand Up @@ -473,15 +539,33 @@ export class EthereumNetwork {
let highestTxBlockHeight = 0
const syncedTokenIds: EdgeTokenId[] = []
for (const [tokenId, tuple] of tokenTxs) {
const safeTokenId = tokenId ?? ''
highestTxBlockHeight = Math.max(highestTxBlockHeight, tuple.blockHeight)
if (tuple.partial === true) {
// External-only rows: a known confirmed transaction may already
// carry an internal component these rows lack, so leave it alone.
// The asset is not reported as synced either; that waits for a
// pass whose internal source answered.
for (const tx of tuple.edgeTransactions) {
const index =
this.ethEngine.txIdMap[safeTokenId]?.[normalizeAddress(tx.txid)]
const known =
index == null
? undefined
: this.ethEngine.transactionList[safeTokenId]?.[index]
if (known == null || known.blockHeight === 0) {
this.ethEngine.addTransaction(tokenId, tx)
}
}
continue
}
Comment thread
j0ntz marked this conversation as resolved.
syncedTokenIds.push(tokenId)
for (const tx of tuple.edgeTransactions) {
this.ethEngine.addTransaction(tokenId, tx)
}
const safeTokenId = tokenId ?? ''
this.ethEngine.walletLocalData.lastTransactionQueryHeight[safeTokenId] =
preUpdateBlockHeight
this.ethEngine.walletLocalData.lastTransactionDate[safeTokenId] = now
highestTxBlockHeight = Math.max(highestTxBlockHeight, tuple.blockHeight)
}
this.ethEngine.walletLocalData.highestTxBlockHeight = Math.max(
this.ethEngine.walletLocalData.highestTxBlockHeight,
Expand Down Expand Up @@ -699,6 +783,8 @@ const makeNetworkAdapter = (
ethEngine: EthereumEngine
): NetworkAdapter => {
switch (config.type) {
case 'alchemy':
return new AlchemyAdapter(ethEngine, config)
case 'amberdata-rpc':
return new AmberdataAdapter(ethEngine, config)
case 'blockbook':
Expand All @@ -709,6 +795,8 @@ const makeNetworkAdapter = (
return new BlockchairAdapter(ethEngine, config)
case 'blockcypher':
return new BlockcypherAdapter(ethEngine, config)
case 'blockscout':
return new BlockscoutAdapter(ethEngine, config)
case 'evmscan':
return new EvmScanAdapter(ethEngine, config)
case 'filfox':
Expand Down
2 changes: 2 additions & 0 deletions src/ethereum/ethereumTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,13 @@ export interface EthereumNetworkInfo {
}

const asNetworkAdaptorConfigType = asValue(
'alchemy',
'amberdata-rpc',
'blockbook',
'blockbook-ws',
'blockchair',
'blockcypher',
'blockscout',
'evmscan',
'filfox',
'pulsechain-scan',
Expand Down
26 changes: 18 additions & 8 deletions src/ethereum/info/robinhoodInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
// Bridged token addresses come from the canonical Arbitrum token bridge, via
// L2GatewayRouter.calculateL2TokenAddress(l1Token). Robinhood Chain's explorer
// lists many same-symbol imposters, so never source these from a token search.
const builtinTokens: EdgeTokenMap = {
export const builtinTokens: EdgeTokenMap = {
'80e0e24718dbfcad49ecaa6f1e6c89a190586ca8': {
currencyCode: 'USDC',
displayName: 'Bridged USDC (Robinhood)',
Expand Down Expand Up @@ -115,18 +115,28 @@ const networkInfo: EthereumNetworkInfo = {
// Blocks arrive about every 96ms, so this is the usual ~2 minute overlap
addressQueryLookbackBlocks: 1250,
networkAdapterConfigs: [
{
// Keyed history source. Alchemy does not serve the `internal` category
// on this network; the `blockscout` adapter below supplies those rows,
// merged into every native-asset sync.
type: 'alchemy',
servers: ['https://robinhood-mainnet.g.alchemy.com/v2/{{alchemyApiKey}}']
},
{
type: 'rpc',
servers: [
'https://rpc.mainnet.chain.robinhood.com',
'https://robinhood-rpc.publicnode.com'
]
'https://robinhood-rpc.publicnode.com',
'https://robinhood-mainnet.g.alchemy.com/v2/{{alchemyApiKey}}'
],
ethBalCheckerContract: '0x8950F12786CAE64F94a02733A260ca6FecDaeD7f'
},
{
// Etherscan V2 does not support chain 4663, so transaction history comes
// from the chain's Blockscout instance, which has no gastracker module.
type: 'evmscan',
gastrackerSupport: false,
// Etherscan V2 does not support chain 4663. The chain's public Blockscout
// instance (300 requests per minute per IP) supplies the internal
// transactions Alchemy lacks here, merged into every native-asset sync,
// and is the history fallback when Alchemy fails.
type: 'blockscout',
servers: ['https://robinhoodchain.blockscout.com']
}
],
Expand All @@ -152,7 +162,7 @@ const networkInfo: EthereumNetworkInfo = {
}
}

const currencyInfo: EdgeCurrencyInfo = {
export const currencyInfo: EdgeCurrencyInfo = {
canReplaceByFee: true,
currencyCode: 'ETH',
evmChainId: 4663,
Expand Down
Loading
Loading