Skip to content

Robinhood Chain: keyed transaction history via Alchemy - #1096

Merged
j0ntz merged 4 commits into
masterfrom
jon/robinhood-alchemy-history
Sep 7, 2026
Merged

j0ntz merged 4 commits into
masterfrom
jon/robinhood-alchemy-history

Conversation

@j0ntz

@j0ntz j0ntz commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Technical Design Document

robinhood-chain.md

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

Runtime, not merge-order: EdgeApp/edge-react-gui#6150 declares ROBINHOOD_INIT in envConfig.ts, which is how alchemyApiKey reaches this plugin. On a gui build without it the alchemy adapter drops its server and Blockscout serves history exactly as before this PR. EdgeApp/edge-currency-accountbased#1062 removes the alchemyApiKey init option and must be rescoped to BlockCypher-only before it lands.

Description

Asana: https://app.asana.com/0/1215088146871429/1218075069562188

Robinhood Chain (pluginId robinhood, chain 4663) read transaction history from the public Blockscout instance alone. Blockscout allows 300 requests per minute per client IP, one wallet sync is 9 calls, and past the limit it answers HTTP 200 with {"message":"Too many requests…","result":null,"status":"0"}. EvmScanAdapter only looked for throttle text in result, so that reply fell through as an ordinary error: no RateLimitError, no backoff, history stayed empty, and a pending send blocked the slider until a resync.

The branch carries these commits:

  1. Classify Blockscout throttling as a rate limit. isEvmScanRateLimitResponse recognizes the Blockscout reply (text in message, result: null) alongside the Etherscan variants, so serialServers backs off. Unit test with the captured JSON.
  2. Add an Alchemy network adapter. AlchemyAdapter (type alchemy) serves fetchTxs from alchemy_getAssetTransfers (external for the native asset, erc20 with contractAddresses per token; fromAddress and toAddress queries; pageKey pagination; withMetadata timestamps) and fetchTokenBalances from alchemy_getTokenBalances. Asset transfers carry no gas data, so the wallet's own spends are completed with eth_getTransactionByHash and eth_getTransactionReceipt from the same endpoint in one batch, and rows are folded per transaction hash with the amount and fee conventions of processEvmScanTransaction (spend includes the fee, self-send nets to the fee, token spend carries parentNetworkFee). Written for any Alchemy-served chain. The {{alchemyApiKey}} placeholder resolves through a helper shared with RpcAdapter, and a server whose key is missing drops out of the waterfall. Eight unit tests cover receive, spend, self-send seen from both directions, zero-value contract call, token receive, token spend, a token pull the wallet did not sign, and multi-transfer folding.
  3. Add a Blockscout network adapter. BlockscoutAdapter (type blockscout) extends the evmscan adapter for the places Blockscout differs: block height from eth_block_number, no gastracker, and a status: "2" partially-indexed reply accepted as a success (that reply was failing the cleaner before). Internal transactions get their own adapter method, NetworkAdapter.fetchInternalTxs, which EthereumNetwork.acquireTxs calls on every native-asset sync whose fetchTxs rows did not already carry internal transfers (tuple.includesInternal) and merges with mergeEdgeTransactions before addTransaction, since the engine replaces a known transaction on an amount change and needs the external and internal halves as one row. If the internal source fails, the pass is marked partial: only unknown or still-unconfirmed txids are applied and the query window stays open. The Blockscout adapter bounds its rate-limit retries (three, 1s/2s/4s) instead of the base adapter's unbounded backoff and pauses fetchInternalTxs for 60s after a throttle reply, so a throttled public instance costs one partial pass rather than holding the native-asset sync open.
  4. Robinhood Chain: history from Alchemy first. alchemy leads networkAdapterConfigs, the Alchemy endpoint joins the RPC server list, blockscout replaces the evmscan entry (internal transactions merged into every native sync, history fallback for everything else), and ethBalCheckerContract points at the eth-balance-checker deployed to 0x8950F12786CAE64F94a02733A260ca6FecDaeD7f in block 52107662 (same runtime bytecode as the checker other Edge plugins use at 0x7263…, deployed from a key generated for this work). src/docs/robinhood-chain.md gets a phase 8 entry, decisions 2 and 3 reopened, and the retrospective line that claimed a Blockscout key would remove the limit without a code change corrected. The commit body's claim that Blockscout is the only source of internal transactions predates the fixup; the design doc and this description carry the corrected mechanism.

Adapter dispatch is asyncWaterfall: the first adapter to answer wins each call, so Blockscout is only queried for history while Alchemy is failing; fetchInternalTxs is the one call it serves on every sync. The Alchemy adapter asks for ['external', 'internal'] on native-asset queries and remembers, per endpoint, a -32602 category is not supported rejection, re-querying with external alone, so a chain where Alchemy serves internal transfers gets them from that adapter without a Blockscout entry.

Tested in the app on the iOS simulator with the plugin linked into the gui at PR 6150's head: three Robinhood Chain wallets of one account syncing at once, engine log showing tokenTxs/tokenBal/blockHeight won by robinhood-mainnet.g.alchemy.com on every sync and no throttle line; the wallet's transaction list rendered; a real send of 0.0005389 ETH reached "Transaction Success" (tx 0xe2be5265fa03f9704960c31d7aa8bbe5fb839a82d7d9c280690be49fc0d21419); and with the Alchemy host made unreachable in the installed bundle, tokenBal ethBalChecker won took over balances and Blockscout's throttle was logged as Rate limit exceeded with backoff. Screenshots in the test-evidence comment.

Review round 1 (2026-09-03): with the linked plugin rebuilt, My Robinhood Chain 2 (an EIP-7702 delegate whose deposits are swept out by internal calls) now lists the LI.FI payout and the sweeps that only txlistinternal reports, and the engine log shows tokenTxs robinhood-mainnet.g.alchemy.com + https://robinhoodchain.blockscout.com won on each native sync alongside the one-time does not serve internal transfers; using external only line from the Alchemy adapter. Those frames were captured with a temporary User-Agent header on the Blockscout request. A follow-up measurement from a phone on mobile data showed the challenge is not IP-specific: robinhoodchain.blockscout.com answers a Cloudflare challenge page to every non-browser agent (the app's CFNetwork and OkHttp agents included) from any IP, while eth.blockscout.com answers all of them. The Blockscout adapter therefore now sends a browser user agent on its /api requests, the same workaround BlockbookAdapter applies to Trezor's Blockbook servers, and that header is product code in this PR.


Note

Medium Risk
Changes sit on the Ethereum wallet sync and history path (new adapters, internal-tx merge, and global Blockscout rate-limit handling), though Robinhood retains Blockscout/RPC fallbacks and the behavior is covered by targeted tests.

Overview
Robinhood Chain (robinhood, chain 4663) no longer relies on the public Blockscout API alone for transaction history and token balances. The plugin now leads with a new AlchemyAdapter (alchemy_getAssetTransfers, alchemy_getTokenBalances, with RPC completion for fees on wallet sends) and falls back to a dedicated BlockscoutAdapter when Alchemy is missing or fails.

Engine and explorer behavior: Native-asset sync merges internal transactions from Blockscout via a new fetchInternalTxs adapter hook and mergeInternalTxs in EthereumNetwork, with partial passes when internal data fails so history is not overwritten incorrectly. EvmScanAdapter now treats Blockscout’s HTTP 200 “Too many requests” body as a RateLimitError (with backoff capped on Blockscout), accepts Blockscout status: "2" partial index replies, and merges txs case-insensitively across sources.

Robinhood config: robinhoodInfo registers alchemy first, swaps evmscan for blockscout, adds Alchemy to RPC servers, and sets ethBalCheckerContract for batched token balances when Alchemy is unavailable. Shared resolveServerApiKey replaces duplicated RPC placeholder logic.

Unit tests cover Alchemy transfer folding, rate-limit classification, and cross-source tx merging; docs and CHANGELOG describe phase 8 (keyed history).

Reviewed by Cursor Bugbot for commit b091e9a. Bugbot is set up for automated code reviews on this repo. Configure here.

@j0ntz

j0ntz commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor Author

📸 Test evidence

robinhood history from alchemy

robinhood history from alchemy

send to deployer confirm

send to deployer confirm

send success

send success

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch from 9ce231c to c8d9dcf Compare September 1, 2026 23:41
@j0ntz
j0ntz marked this pull request as ready for review September 1, 2026 23:41
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@peachbits peachbits left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the third commit, it's not accurate to say that blockscout will handle the internal transactions. Alchemy goes first and if it's successful then blockscout wouldn't be used at all. at best, some internal transaction will come in when alchemy fails and blockscout just happens to work. I don't think it's fixable unless you add a specific fetchInternal method to the network adaptor and create a blockscout adaptor that implements it

Comment thread src/ethereum/networkAdapters/AlchemyAdapter.ts Outdated
Comment thread src/ethereum/networkAdapters/AlchemyAdapter.ts Outdated
@j0ntz

j0ntz commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: under the waterfall Blockscout was never asked while Alchemy answered, so that sentence in the third commit was wrong. Added NetworkAdapter.fetchInternalTxs and a BlockscoutAdapter that implements it through txlistinternal (it also handles Blockscout's status "2" partially indexed reply and its eth_block_number). EthereumNetwork.acquireTxs calls fetchInternalTxs on every native-asset sync whose fetchTxs rows did not already include internal transfers (tuple.includesInternal) and merges the rows with mergeEdgeTransactions before addTransaction, which replaces a known transaction on an amount change and so needs the external and internal halves as one row. When the internal source fails, the pass is marked partial: only unknown or still-unconfirmed txids are applied and the query window stays open until it recovers. Robinhood now configures blockscout in place of evmscan. Verified in the app on My Robinhood Chain 2 (an EIP-7702 delegate whose deposits are swept by internal calls): the LI.FI payout and the sweeps render, and the engine log reports tokenTxs won by alchemy + blockscout on each sync; screenshots follow in the test-evidence comment.

@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch from c8d9dcf to ad22eb9 Compare September 3, 2026 19:15
@j0ntz

j0ntz commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor Author

📸🪓 Test evidence (review round 1: internal transactions)

🪓 Hack-forced evidence: added a browser User-Agent header to the Blockscout request in EvmScanAdapter.fetchGetEtherscan (uncommitted, reverted) because Cloudflare answers this host's egress with a challenge for non-browser agents. Temporary uncommitted edit, reverted before commit; the marked frames prove the rendering, not the trigger.

🪓 HACK-FORCED: wallet2 scene blockscout reachable via ua hack

🪓 HACK-FORCED: wallet2 scene blockscout reachable via ua hack

🪓 HACK-FORCED: wallet2 history with internal sweeps

🪓 HACK-FORCED: wallet2 history with internal sweeps

Captured by the agent's in-app test run (build-and-test).

@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch 2 times, most recently from 900c4c3 to 85bf356 Compare September 3, 2026 22:11
Comment thread src/ethereum/networkAdapters/AlchemyAdapter.ts
Comment thread src/ethereum/networkAdapters/AlchemyAdapter.ts Outdated
@j0ntz

j0ntz commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Added a browser User-Agent on the Blockscout adapter's /api requests (fixup on "Add a Blockscout network adapter"), after measuring the challenge from two unrelated IPs on 2026-09-03.

From a phone on mobile data (T-Mobile, 172.56.211.158) and from the build host, robinhoodchain.blockscout.com answers Cloudflare's managed challenge page (HTTP 403, cf-mitigated: challenge) to every non-browser agent, the app's own included, while a browser agent gets the JSON; eth.blockscout.com answers all of them:

User-Agent robinhoodchain.blockscout.com eth.blockscout.com
Chrome browser string 200 200
Edge/4.51.0 CFNetwork/1568 Darwin/24.5.0 (iOS app) 403 200
okhttp/4.12.0 (Android app) 403 200
Edge/4.51.0 403 200
none 403 200

Without the header the app's internal-transaction fetch fails soft on every sync and Blockscout contributes nothing on this chain. The header follows the precedent in BlockbookAdapter.fetchGetBlockbook, which sends User-Agent: http.agent to Trezor's Blockbook servers for the same reason.

Verified with the compiled adapter and its own header, no local edits, from the build host whose IP Blockscout challenges: fetchBlockheight 53756406, fetchInternalTxs for 0x9858Ef… 8 rows (the LI.FI payout 0x7db45ef7… and the delegate sweeps), fetchTxs 15 rows, the status: "2" reply accepted.

@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch from 85bf356 to 41e745d Compare September 3, 2026 22:27
Comment thread src/ethereum/EthereumNetwork.ts
Comment thread src/ethereum/networkAdapters/AlchemyAdapter.ts
@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch 2 times, most recently from d008257 to c584ab9 Compare September 3, 2026 23:35

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/ethereum/networkAdapters/AlchemyAdapter.ts
Comment thread src/ethereum/EthereumNetwork.ts
@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch 2 times, most recently from 06e05a0 to d18a742 Compare September 3, 2026 23:50

@peachbits peachbits left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of the fetchInternalTxs / blockscout round. The merge design holds up: internal rows reach addTransaction in the same pass as the external rows, includesInternal keeps EvmScan-backed chains and Alchemy-with-internal chains from merging twice, and the reverted-send receipt lookup closes the pending-slider gap. tsc is clean and the PR's tests pass (the one failing test locally, FTM info-server fees, is pre-existing and untouched by this branch). Remaining points are below; none are blockers.

Comment thread src/ethereum/info/robinhoodInfo.ts Outdated
Comment thread src/docs/robinhood-chain.md Outdated

@peachbits peachbits left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved, just need to fix some stale documentation

@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch from d18a742 to 1cafac4 Compare September 7, 2026 22:58
@j0ntz

j0ntz commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Both stale documentation passages are rewritten and squashed into the history commit; the two inline threads carry the details.

@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch from 1cafac4 to 2f73214 Compare September 7, 2026 23:09
Blockscout answers a throttled request with status "0", result null
and the text in message, which fetchGetEtherscan only ever looked for
in result. The reply fell through as an ordinary error, so
serialServers never backed off and the engine kept polling at full
cadence while history stayed empty.
Transaction history from alchemy_getAssetTransfers (external and erc20,
both directions, pageKey pagination, withMetadata timestamps) and token
balances from alchemy_getTokenBalances. Transfers carry no gas data, so
the wallet's own spends are completed with eth_getTransactionByHash and
eth_getTransactionReceipt from the same endpoint, keeping the amount and
fee conventions of the evmscan path. The {{alchemyApiKey}} placeholder
resolves through the same helper RpcAdapter uses, and a server whose key
is missing drops out of the waterfall instead of failing every call.
Blockscout differs from Etherscan in block height, gastracker, its
status "2" partial-index reply, and the throttle text in message.
Internal transactions get their own adapter method, fetchInternalTxs,
which EthereumNetwork merges into every native-asset sync whose
fetchTxs rows did not already carry them (the tuple's includesInternal
flag), so a chain whose primary history source cannot see contract-paid
ETH still shows it. The merge happens inside one pass because
addTransaction replaces a known transaction whose amount changed.
The alchemy adapter leads the waterfall, the Alchemy endpoint joins the
RPC server list, and the public Blockscout instance stays as the
fallback history source and the only source of internal transactions.
@j0ntz
j0ntz enabled auto-merge September 7, 2026 23:28
@j0ntz
j0ntz force-pushed the jon/robinhood-alchemy-history branch from 2f73214 to b091e9a Compare September 7, 2026 23:28

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b091e9a. Configure here.

const cleanKey = asEthereumInitKeys(match[1])
const apiKey = ethEngine.initOptions[cleanKey]
if (typeof apiKey === 'string') {
return url.replace(match[0], apiKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty Alchemy key not dropped

Low Severity

resolveServerApiKey treats an empty-string alchemyApiKey as valid and substitutes it into the URL. AlchemyAdapter then keeps that server instead of dropping it, so history hits an unauthenticated Alchemy host and waits on waterfall fallback every sync rather than using Blockscout immediately.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b091e9a. Configure here.

@j0ntz
j0ntz merged commit 40aabde into master Sep 7, 2026
4 checks passed
@j0ntz
j0ntz deleted the jon/robinhood-alchemy-history branch September 7, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants