From a9d1a250eb3e6c70f94f3b1c656a6114e9c6eb26 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 16 Sep 2026 14:13:00 -0700 Subject: [PATCH 1/2] Count native ERC-20 interface moves in history Arc's native USDC has an optional ERC-20 view at 0x3600...0000 with 6 decimals of precision against the native 18. A transfer through that contract moves the native balance, but Alchemy reports it only as an erc20 row on the contract, and Etherscan only in tokentx, never as an external or internal transfer, so native history missed it. A network can now name that contract as nativeErc20Interface. A native sync on the Alchemy or evmscan adapter also reads the contract's transfers, scales each value to the native denomination, and sums them with the transaction's other native rows. The evmscan rows carry a zero gas price, since the same transaction's txlist row already has the fee. --- src/ethereum/ethereumTypes.ts | 12 +++ .../networkAdapters/AlchemyAdapter.ts | 83 +++++++++++++++++-- .../networkAdapters/EvmScanAdapter.ts | 63 ++++++++++++-- .../network/alchemyTxProcessing.test.ts | 39 ++++++++- .../network/evmScanNativeInterface.test.ts | 75 +++++++++++++++++ 5 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 test/ethereum/network/evmScanNativeInterface.test.ts diff --git a/src/ethereum/ethereumTypes.ts b/src/ethereum/ethereumTypes.ts index 59e38af36..0008dfa75 100644 --- a/src/ethereum/ethereumTypes.ts +++ b/src/ethereum/ethereumTypes.ts @@ -113,6 +113,18 @@ export interface EthereumNetworkInfo { nodeInterfaceAddress: string } disableEvmScanInternal?: boolean + /** + * A contract that exposes the native asset as an ERC-20 token at its own + * precision, such as Arc's USDC at 0x3600…0000 (native 18 decimals, the + * interface 6). A transfer through it moves the native balance without an + * external or internal value transfer, so native history must include the + * contract's Transfer events, scaled up to the native denomination. + */ + nativeErc20Interface?: { + contractAddress: string + /** One whole unit at the interface's precision, such as '1000000' */ + multiplier: string + } // Engine behavior flags (chain-specific quirks) useRpcBalanceForMaxSpendNative?: boolean nativeSendPrechargeWei?: string diff --git a/src/ethereum/networkAdapters/AlchemyAdapter.ts b/src/ethereum/networkAdapters/AlchemyAdapter.ts index 17b67ebc3..7fe44f106 100644 --- a/src/ethereum/networkAdapters/AlchemyAdapter.ts +++ b/src/ethereum/networkAdapters/AlchemyAdapter.ts @@ -1,4 +1,4 @@ -import { add, gt, mul, sub } from 'biggystring' +import { add, div, gt, mul, sub } from 'biggystring' import { asArray, asEither, @@ -21,7 +21,7 @@ import { EthereumNetworkUpdate, getFeeRateUsed } from '../EthereumNetwork' -import { EthereumTxOtherParams } from '../ethereumTypes' +import { EthereumNetworkInfo, EthereumTxOtherParams } from '../ethereumTypes' import { resolveServerApiKey } from './apiKeyTemplate' import { TransactionProcessingContext } from './EvmScanAdapter' import { GetTxsParams, NetworkAdapter } from './networkAdapterTypes' @@ -192,9 +192,14 @@ export class AlchemyAdapter extends NetworkAdapter { contractAddress = location.contractAddress } + // The native asset's ERC-20 interface moves native value without an + // external or internal transfer, so a native sync asks for its rows too. + const { nativeErc20Interface } = this.ethEngine.networkInfo + const nativeInterface = tokenId == null ? nativeErc20Interface : undefined + const { result, server } = await this.serialServers(async baseUrl => { const hostname = parse(baseUrl).hostname - const [sent, received] = await Promise.all([ + const [sent, received, interfaceTransfers] = await Promise.all([ this.fetchAssetTransfers(baseUrl, { startBlock, contractAddress, @@ -204,7 +209,14 @@ export class AlchemyAdapter extends NetworkAdapter { startBlock, contractAddress, toAddress: address - }) + }), + nativeInterface == null + ? [] + : this.fetchNativeInterfaceTransfers(baseUrl, { + startBlock, + address, + nativeInterface + }) ]) // A native query that kept the `internal` category through both @@ -214,9 +226,12 @@ export class AlchemyAdapter extends NetworkAdapter { // consistently external-only and the engine fetches them elsewhere. const includesInternal = sent.includedInternal && received.includedInternal - const transfers = [...sent.transfers, ...received.transfers].filter( - transfer => includesInternal || transfer.category !== 'internal' - ) + const transfers = [ + ...[...sent.transfers, ...received.transfers].filter( + transfer => includesInternal || transfer.category !== 'internal' + ), + ...interfaceTransfers + ] // Only the wallet's own outgoing transactions need gas data: const spendTxids = new Set() @@ -264,6 +279,39 @@ export class AlchemyAdapter extends NetworkAdapter { } } + /** + * Both directions of the native asset's ERC-20 interface transfers, with + * each value scaled from the interface's precision to the native one. + */ + private async fetchNativeInterfaceTransfers( + baseUrl: string, + query: { + startBlock: number + address: string + nativeInterface: NonNullable + } + ): Promise { + const { startBlock, address, nativeInterface } = query + const { contractAddress } = nativeInterface + const [sent, received] = await Promise.all([ + this.fetchAssetTransfers(baseUrl, { + startBlock, + contractAddress, + fromAddress: address + }), + this.fetchAssetTransfers(baseUrl, { + startBlock, + contractAddress, + toAddress: address + }) + ]) + return scaleInterfaceTransfers( + [...sent.transfers, ...received.transfers], + this.ethEngine.currencyInfo.denominations[0].multiplier, + nativeInterface.multiplier + ) + } + /** * Pages through `alchemy_getAssetTransfers` for one direction of one asset. * `includedInternal` reports whether the rows came from a query that kept @@ -617,6 +665,27 @@ export function processAlchemyTransfers( return edgeTransactions } +/** + * Restates ERC-20 interface transfers of the native asset in native units, + * so they sum with the external and internal transfers of the same asset. + */ +export function scaleInterfaceTransfers( + transfers: AlchemyAssetTransfer[], + nativeMultiplier: string, + interfaceMultiplier: string +): AlchemyAssetTransfer[] { + const scale = div(nativeMultiplier, interfaceMultiplier) + return transfers.map(transfer => ({ + ...transfer, + rawContract: { + ...transfer.rawContract, + value: decimalToHex( + mul(hexToDecimal(transfer.rawContract.value ?? '0x0'), scale) + ) + } + })) +} + export type AlchemyAssetTransfer = ReturnType export const asAlchemyAssetTransfer = asObject({ blockNum: asString, diff --git a/src/ethereum/networkAdapters/EvmScanAdapter.ts b/src/ethereum/networkAdapters/EvmScanAdapter.ts index ed342f8d2..8b456c702 100644 --- a/src/ethereum/networkAdapters/EvmScanAdapter.ts +++ b/src/ethereum/networkAdapters/EvmScanAdapter.ts @@ -1,4 +1,4 @@ -import { add, max, mul, sub } from 'biggystring' +import { add, div, max, mul, sub } from 'biggystring' import { asArray, asEither, @@ -48,6 +48,11 @@ import { interface GetEthscanAllTxsOptions { contractAddress?: string + /** + * Set when `contractAddress` is the native asset's ERC-20 interface: the + * factor from the interface's units to native units. + */ + nativeInterfaceScale?: string searchRegularTxs?: boolean } @@ -257,10 +262,32 @@ export class EvmScanAdapter< { searchRegularTxs: false } ) } + // Transfers through the native asset's ERC-20 interface move native + // value that neither list above reports: + let txsInterfaceResp: GetEthscanAllTxsResponse = { + allTransactions: [], + server: '' + } + const { nativeErc20Interface } = this.ethEngine.networkInfo + if (nativeErc20Interface != null) { + txsInterfaceResp = await this.getAllTxsEthscan( + startBlock, + tokenId, + asEvmScanTokenTransaction, + { + contractAddress: nativeErc20Interface.contractAddress, + nativeInterfaceScale: div( + this.ethEngine.currencyInfo.denominations[0].multiplier, + nativeErc20Interface.multiplier + ) + } + ) + } server = txsRegularResp.server ?? txsInternalResp.server ?? '' allTransactions = mergeEdgeTransactions([ ...txsRegularResp.allTransactions, - ...txsInternalResp.allTransactions + ...txsInternalResp.allTransactions, + ...txsInterfaceResp.allTransactions ]) includesInternal = this.ethEngine.networkInfo.disableEvmScanInternal !== true @@ -357,7 +384,11 @@ export class EvmScanAdapter< >, options: GetEthscanAllTxsOptions ): Promise { - const { contractAddress, searchRegularTxs = false } = options + const { + contractAddress, + nativeInterfaceScale, + searchRegularTxs = false + } = options const address = this.ethEngine.walletLocalData.publicKey let page = 1 @@ -367,7 +398,7 @@ export class EvmScanAdapter< const offset = NUM_TRANSACTIONS_TO_QUERY let startUrl - if (tokenId === null) { + if (contractAddress == null) { startUrl = `?action=${ searchRegularTxs ? 'txlist' : 'txlistinternal' }&module=account` @@ -404,7 +435,13 @@ export class EvmScanAdapter< const transactions = asArray(asUnknown)(response.response.result) for (let i = 0; i < transactions.length; i++) { try { - const cleanedTx = asTransaction(transactions[i]) + let cleanedTx = asTransaction(transactions[i]) + if (nativeInterfaceScale != null && 'tokenDecimal' in cleanedTx) { + cleanedTx = asNativeInterfaceTransaction( + cleanedTx, + nativeInterfaceScale + ) + } const l1RollupFee = await this.getL1RollupFee(cleanedTx) const tx = processEvmScanTransaction( { @@ -605,6 +642,22 @@ export function processEvmScanTransaction( // or should be this.addTransaction(4, tokenId, edgeTransaction)? } +/** + * Restates a transfer through the native asset's ERC-20 interface as a native + * value movement. The gas price is zeroed because the same transaction's + * `txlist` row already carries its fee, and merging adds amounts. + */ +export function asNativeInterfaceTransaction( + tx: EvmScanTokenTransaction, + nativeInterfaceScale: string +): EvmScanTokenTransaction { + return { + ...tx, + value: mul(tx.value, nativeInterfaceScale), + gasPrice: '0' + } +} + export function mergeEdgeTransactions( transactions: EdgeTransaction[] ): EdgeTransaction[] { diff --git a/test/ethereum/network/alchemyTxProcessing.test.ts b/test/ethereum/network/alchemyTxProcessing.test.ts index 9f08e680d..07fa89bfb 100644 --- a/test/ethereum/network/alchemyTxProcessing.test.ts +++ b/test/ethereum/network/alchemyTxProcessing.test.ts @@ -10,7 +10,8 @@ import { AlchemyAssetTransfer, AlchemyTxDetails, makeFailedSend, - processAlchemyTransfers + processAlchemyTransfers, + scaleInterfaceTransfers } from '../../../src/ethereum/networkAdapters/AlchemyAdapter' import { TransactionProcessingContext } from '../../../src/ethereum/networkAdapters/EvmScanAdapter' @@ -131,6 +132,42 @@ describe('AlchemyAdapter transfer processing', function () { assert.deepEqual(txs[0].ourReceiveAddresses, []) }) + it('sums native ERC-20 interface transfers with internal ones', function () { + // From Arc tx 0xc5925f9d: an internal transfer of 2 USDC and an + // ERC-20 interface transfer of 24.75, the interface at 6 decimals. + const hash = + '0xc5925f9d7f7b05d8fc1ee3a74976e3ca73e2b6a68d4c54dda708a4192fb1601a' + const interfaceAddress = '0x3600000000000000000000000000000000000000' + const [scaled] = scaleInterfaceTransfers( + [ + makeTransfer({ + hash, + uniqueId: `${hash}:log:7`, + category: 'erc20', + rawContract: { value: '0x179a7b0', address: interfaceAddress } + }) + ], + '1000000000000000000', + '1000000' + ) + assert.equal(scaled.rawContract.value, '0x15779a9de6eeb0000') + const [tx] = processAlchemyTransfers( + nativeContext, + [ + makeTransfer({ + hash, + uniqueId: `${hash}:internal:0_0`, + category: 'internal', + rawContract: { value: '0x1bc16d674ec80000', address: null } + }), + scaled + ], + new Map() + ) + assert.equal(tx.nativeAmount, '26750000000000000000') + assert.equal(tx.isSend, false) + }) + it('keeps a zero-value contract call as a fee-only spend', function () { const hash = '0x63395211dc9af5a8e0711d6e03f21c5f5733f39c8be7a72b6ece5339a925bf4a' diff --git a/test/ethereum/network/evmScanNativeInterface.test.ts b/test/ethereum/network/evmScanNativeInterface.test.ts new file mode 100644 index 000000000..b920d942e --- /dev/null +++ b/test/ethereum/network/evmScanNativeInterface.test.ts @@ -0,0 +1,75 @@ +import { assert } from 'chai' +import { describe, it } from 'mocha' + +import { + asEvmScanTokenTransaction, + asEvmScanTransaction, + asNativeInterfaceTransaction, + mergeEdgeTransactions, + processEvmScanTransaction, + TransactionProcessingContext +} from '../../../src/ethereum/networkAdapters/EvmScanAdapter' +import { allTokensMapFixture } from './allTokensMapFixture' +import { currencyInfoFixture } from './currencyInfoFixture' + +const ourAddress = '0x8feec0972935bb18402d0031d448135f7e0a2813' +const context: TransactionProcessingContext = { + allTokensMap: allTokensMapFixture, + currencyInfo: currencyInfoFixture, + forWhichAddress: ourAddress, + forWhichTokenId: null, + forWhichWalletId: 'walletId' +} + +// Captured 2026-09-16 from Etherscan V2 (chainid 5042): a LI.FI swap that +// sent no value and had 2.5 USDC pulled through Arc's USDC interface. +const txlistRow = asEvmScanTransaction({ + blockNumber: '21227906', + timeStamp: '1789597507', + hash: '0xf4a0765860d75ae2cf15bdcf9bef80cdc63aeef1e32114a7cf6388c9a449957a', + nonce: '1', + from: ourAddress, + to: '0xa4072583658fae592a3506a42431cb6316a8d40b', + value: '0', + gas: '1061060', + gasPrice: '21000000000', + gasUsed: '289223', + isError: '0', + confirmations: '6660' +}) +const tokentxRow = asEvmScanTokenTransaction({ + blockNumber: '21227906', + timeStamp: '1789597507', + hash: '0xf4a0765860d75ae2cf15bdcf9bef80cdc63aeef1e32114a7cf6388c9a449957a', + nonce: '1', + from: ourAddress, + to: '0xa4072583658fae592a3506a42431cb6316a8d40b', + value: '2500000', + gas: '1061060', + gasPrice: '21000000000', + gasUsed: '289223', + confirmations: '6660', + contractAddress: '0x3600000000000000000000000000000000000000', + tokenName: 'USDC', + tokenSymbol: 'USDC', + tokenDecimal: '6' +}) + +describe('EvmScanAdapter native ERC-20 interface transfers', function () { + it('counts an interface pull once, with the fee once', function () { + const fee = '6073683000000000' + const regular = processEvmScanTransaction(context, txlistRow, '0') + const pulled = processEvmScanTransaction( + context, + asNativeInterfaceTransaction(tokentxRow, '1000000000000'), + '0' + ) + assert.equal(pulled.nativeAmount, '-2500000000000000000') + assert.equal(pulled.networkFee, '0') + + const [tx] = mergeEdgeTransactions([regular, pulled]) + assert.equal(tx.nativeAmount, '-2506073683000000000') + assert.equal(tx.networkFee, fee) + assert.equal(tx.isSend, true) + }) +}) From 48566040076aa6cea7c1c754af8e076e77244b87 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Wed, 16 Sep 2026 14:14:54 -0700 Subject: [PATCH 2/2] Add Arc support Arc is Circle's EVM chain (5042) with USDC as its gas token. The native balance carries 18 decimals; USDC at 0x3600...0000 is an ERC-20 view of that same balance at 6 decimals, so it is named as the network's nativeErc20Interface rather than listed as a token, which would show the same money twice. History comes from Alchemy, with Etherscan V2 as the fallback and the only source in a build without an Alchemy key; both fold in interface transfers. EURC and cirBTC are the built-in tokens, each checked against the contract's name, symbol and decimals. The base fee holds near 20 gwei. --- CHANGELOG.md | 3 + src/ethereum/ethereumInfos.ts | 2 + src/ethereum/info/arcInfo.ts | 161 ++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 src/ethereum/info/arcInfo.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fffb1163..dff25329e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- added: Arc support (`arc`, EVM chain 5042) +- added: (EVM) `nativeErc20Interface` network option, counting ERC-20 interface transfers as native history + ## 4.94.0 (2026-09-16) - added: (Tron) Spend prebuilt `TriggerSmartContract` calls passed in `otherParams` diff --git a/src/ethereum/ethereumInfos.ts b/src/ethereum/ethereumInfos.ts index 7326aca03..f420cb347 100644 --- a/src/ethereum/ethereumInfos.ts +++ b/src/ethereum/ethereumInfos.ts @@ -1,6 +1,7 @@ import { abstract } from './info/abstractInfo' import { amoy } from './info/amoyInfo' import { arbitrum } from './info/arbitrumInfo' +import { arc } from './info/arcInfo' import { avalanche } from './info/avalancheInfo' import { base } from './info/baseInfo' import { binancesmartchain } from './info/binancesmartchainInfo' @@ -31,6 +32,7 @@ export const ethereumPlugins = { abstract, amoy, arbitrum, + arc, avalanche, base, binancesmartchain, diff --git a/src/ethereum/info/arcInfo.ts b/src/ethereum/info/arcInfo.ts new file mode 100644 index 000000000..ecde37291 --- /dev/null +++ b/src/ethereum/info/arcInfo.ts @@ -0,0 +1,161 @@ +import { EdgeCurrencyInfo, EdgeTokenMap } from 'edge-core-js/types' + +import { makeOuterPlugin } from '../../common/innerPlugin' +import { createEvmTokenId, makeMetaTokens } from '../../common/tokenHelpers' +import type { EthereumTools } from '../EthereumTools' +import { + asEthereumInfoPayload, + EthereumFees, + EthereumInfoPayload, + EthereumNetworkInfo +} from '../ethereumTypes' +import { + evmCustomFeeTemplate, + evmCustomTokenTemplate, + evmMemoOptions, + makeEvmDefaultSettings +} from './ethereumCommonInfo' + +// Addresses from Arc's published contract list, each confirmed against the +// contract's own name, symbol and decimals. USDC at 0x3600…0000 is not listed: +// it is the native balance seen through an ERC-20 interface, so as a token it +// would show the same money twice. +export const builtinTokens: EdgeTokenMap = { + bef5f6d51cb62b58e6a8f77868681825c6fe21c1: { + currencyCode: 'EURC', + displayName: 'EURC', + denominations: [{ name: 'EURC', multiplier: '1000000' }], + networkLocation: { + contractAddress: '0xbEf5f6d51CB62b58e6A8f77868681825C6fe21c1' + } + }, + '171a4217b86a807a64eb94757db6849fb4bdbaa0': { + currencyCode: 'cirBTC', + displayName: 'Circle Wrapped Bitcoin', + denominations: [{ name: 'cirBTC', multiplier: '100000000' }], + networkLocation: { + contractAddress: '0x171A4217b86A807A64eB94757Db6849fb4bDbAA0' + } + } +} + +// Fees are in USDC wei (18 decimals). The base fee holds near 20 gwei, about +// $0.0004 for a plain transfer, and most blocks tip a few gwei. +const networkFees: EthereumFees = { + default: { + baseFee: undefined, + baseFeeMultiplier: { + lowFee: '1', + standardFeeLow: '1.25', + standardFeeHigh: '1.5', + highFee: '1.75' + }, + gasLimit: { + regularTransaction: '21000', + tokenTransaction: '300000', + minGasLimit: '21000' + }, + gasPrice: { + lowFee: '20000000001', + standardFeeLow: '22000000001', + standardFeeHigh: '30000000001', + standardFeeLowAmount: '100000000000000000', + standardFeeHighAmount: '10000000000000000000', + highFee: '40000000001', + minGasPrice: '20000000000' + }, + minPriorityFee: '1000000000' + } +} + +const networkInfo: EthereumNetworkInfo = { + // Blocks arrive about every 0.5s, so this is the usual ~2 minute overlap + addressQueryLookbackBlocks: 240, + networkAdapterConfigs: [ + { + // History source. Both this adapter and `evmscan` below add the native + // ERC-20 interface's transfers, which neither reports as native value. + type: 'alchemy', + servers: ['https://arc-mainnet.g.alchemy.com/v2/{{alchemyApiKey}}'] + }, + { + type: 'rpc', + servers: [ + 'https://rpc.mainnet.arc.io', + 'https://rpc.drpc.mainnet.arc.io', + 'https://rpc.quicknode.mainnet.arc.io', + 'https://arc-mainnet.g.alchemy.com/v2/{{alchemyApiKey}}' + ] + }, + { + // History fallback, and the only source in a build without an Alchemy + // key. Etherscan V2 serves chain 5042. + type: 'evmscan', + servers: ['https://api.etherscan.io'] + } + ], + nativeErc20Interface: { + contractAddress: '0x3600000000000000000000000000000000000000', + multiplier: '1000000' + }, + uriNetworks: ['arc'], + ercTokenStandard: 'ERC20', + chainParams: { + chainId: 5042, + name: 'Arc' + }, + supportsEIP1559: true, + hdPathCoinType: 60, + pluginMnemonicKeyName: 'arcMnemonic', + pluginRegularKeyName: 'arcKey', + evmGasStationUrl: null, + networkFees +} + +export const currencyInfo: EdgeCurrencyInfo = { + canReplaceByFee: true, + currencyCode: 'USDC', + evmChainId: 5042, + customFeeTemplate: evmCustomFeeTemplate, + customTokenTemplate: evmCustomTokenTemplate, + chainDisplayName: 'Arc', + assetDisplayName: 'USD Coin', + memoOptions: evmMemoOptions, + pluginId: 'arc', + walletType: 'wallet:arc', + + // Explorers: + addressExplorer: 'https://arc.etherscan.io/address/%s', + transactionExplorer: 'https://arc.etherscan.io/tx/%s', + + denominations: [ + { + name: 'USDC', + multiplier: '1000000000000000000', + symbol: 'USDC' + } + ], + + usesChangeServer: true, + + // Deprecated: + defaultSettings: makeEvmDefaultSettings(networkInfo), + displayName: 'Arc', + metaTokens: makeMetaTokens(builtinTokens) +} + +export const arc = makeOuterPlugin< + EthereumNetworkInfo, + EthereumTools, + EthereumInfoPayload +>({ + builtinTokens, + currencyInfo, + asInfoPayload: asEthereumInfoPayload, + createTokenId: createEvmTokenId, + networkInfo, + + async getInnerPlugin() { + return await import('../EthereumTools') + } +})