import { ethers } from 'ethers'; import type { Request, Response } from 'express'; import { checkpointIndexerConfig as cfg } from './config'; import { buildAddressIndex, getAddressTransactions, summarizeAddressActivity, } from './addressIndex'; import { chain138ExplorerBase, chain138ExplorerTxUrl, enrichRegistryLogsEnvelope, } from './chain138Explorer'; import { decodePaymentAttestedLogs, PAYMENT_ATTESTED_TOPIC0 } from './iso20022LogDecode'; import { CHAIN138_ACTIVITY_NOTIFIED_TOPIC0, decodeChain138ActivityNotifiedLogs, } from './participantSurfaceLogDecode'; /** Etherscan V2-shaped envelope for tooling compatibility. */ function ok(result: unknown, message = 'OK') { return { status: '1', message, result }; } function err(message: string, result: unknown = null) { return { status: '0', message, result }; } function normalizeAddress(address: string): string { try { return ethers.getAddress(address); } catch { return ethers.getAddress(address.toLowerCase()); } } function padTopicAddress(address: string): string { return ethers.zeroPadValue(normalizeAddress(address), 32); } async function blockscoutTxList(address: string, page: number, offset: number) { const base = cfg.blockscoutApi.replace(/\/$/, ''); const url = `${base}/addresses/${normalizeAddress(address)}/transactions?page=${page}&offset=${offset}`; const res = await fetch(url); if (!res.ok) throw new Error(`blockscout ${res.status}`); const body = (await res.json()) as { items?: Array> }; return (body.items ?? []).map((tx) => ({ blockNumber: String(tx.block_number ?? tx.blockNumber ?? ''), timeStamp: tx.timestamp ? String(Math.floor(new Date(String(tx.timestamp)).getTime() / 1000)) : '', hash: tx.hash, from: (tx.from as { hash?: string })?.hash ?? tx.from, to: (tx.to as { hash?: string })?.hash ?? tx.to, value: String(tx.value ?? '0'), gas: String((tx.fee as { gas_used?: string })?.gas_used ?? tx.gas_used ?? ''), gasPrice: String(tx.gas_price ?? tx.gasPrice ?? '0'), isError: tx.status === 'ok' || tx.status === 1 || tx.status === '0x1' ? '0' : '1', txreceipt_status: tx.status === 'ok' || tx.status === 1 ? '1' : '0', input: tx.raw_input ?? tx.input ?? '0x', contractAddress: '', cumulativeGasUsed: '', confirmations: String(tx.confirmations ?? ''), })); } async function etherscanV2GetLogs(params: URLSearchParams, apiKey: string) { const q = new URLSearchParams(params); q.set('chainid', '1'); q.set('module', 'logs'); q.set('action', 'getLogs'); if (apiKey) q.set('apikey', apiKey); const url = `https://api.etherscan.io/v2/api?${q.toString()}`; const res = await fetch(url); return res.json(); } export function chainlistSupplement(_req: Request, res: Response) { const registry = cfg.addressActivityRegistry || null; res.json({ comments: 'Supplemental chainlist for networks absent from official Etherscan V2 chainlist. Not maintained by Etherscan.', totalcount: 2, result: [ { chainname: 'Defi Oracle Meta Mainnet', chainid: '138', blockexplorer: 'https://explorer.d-bis.org/', apiurl: `${publicBase(_req)}/v2/api?chainid=138`, status: 1, comment: 'Blockscout native + project attestation shim', }, { chainname: 'Ethereum Mainnet (Chain 138 attestation layer)', chainid: '1', attestationLayer: true, blockexplorer: 'https://etherscan.io/', apiurl: `${publicBase(_req)}/v2/api?chainid=1&module=chain138mirror`, status: 1, comment: 'Query mirror/registry logs via chain138mirror module on this shim (not native Etherscan V2 module=account)', contracts: { transactionMirror: cfg.legacyMirror, addressActivityRegistry: registry, addressActivityRegistryV2: cfg.addressActivityRegistryV2 || null, iso20022IntakeGateway: cfg.iso20022IntakeGateway || null, checkpointHub: cfg.checkpointProxy || null, }, }, ], }); } function publicBase(req: Request): string { const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol; const host = req.headers['x-forwarded-host'] || req.headers.host || `127.0.0.1:${cfg.port}`; return `${proto}://${host}`; } export async function etherscanV2Api(req: Request, res: Response) { const q = req.query; const chainid = String(q.chainid ?? ''); const module = String(q.module ?? ''); const action = String(q.action ?? ''); const apiKey = String(q.apikey ?? process.env.ETHERSCAN_API_KEY ?? ''); if (!chainid) { return res.json( err( 'Missing or unsupported chainid parameter (required for v2 api), please see chainlist-supplement for Chain 138' ) ); } try { if (chainid === '138') { return res.json(await handleChain138(module, action, q)); } if (chainid === '1' && module === 'chain138mirror') { return res.json(await handleMainnetAttestation(action, q, apiKey)); } if (chainid === '1' && module === 'proxy') { return res.json( err('Use official https://api.etherscan.io/v2/api for chainid=1 proxy module') ); } return res.json( err( `Chain ${chainid} is not served by this shim. Chain 138 native: chainid=138. Chain 138 on mainnet attestation: chainid=1&module=chain138mirror` ) ); } catch (e) { return res.json(err(String(e))); } } async function handleChain138( module: string, action: string, q: Request['query'] ): Promise<{ status: string; message: string; result: unknown }> { const address = String(q.address ?? ''); if (module === 'account' && action === 'balance') { if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return err('Invalid address'); const provider = new ethers.JsonRpcProvider(cfg.chain138Rpc); const bal = await provider.getBalance(normalizeAddress(address)); return ok(bal.toString()); } if (module === 'account' && action === 'txlist') { if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return err('Invalid address'); const page = parseInt(String(q.page ?? '1'), 10) || 1; const offset = Math.min(parseInt(String(q.offset ?? '10'), 10) || 10, 100); const items = await blockscoutTxList(normalizeAddress(address), page, offset); return ok(items); } if (module === 'chain138' && action === 'attestation') { const txHash = String(q.txhash ?? q.txHash ?? ''); if (!/^0x[a-fA-F0-9]{64}$/.test(txHash)) return err('Invalid txhash'); const base = publicBaseFromQuery(q); const r = await fetch(`${base}/v1/tx/${txHash}/attestation`); if (!r.ok) return err('attestation not found'); return ok(await r.json()); } if (module === 'chain138' && action === 'participantactivity') { if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return err('Invalid address'); const normalized = normalizeAddress(address); const index = buildAddressIndex(cfg.batchPayloadDir); const { total, items } = getAddressTransactions( index, normalized, Math.min(parseInt(String(q.offset ?? '50'), 10) || 50, 200), parseInt(String(q.page ?? '0'), 10) || 0 ); const summary = summarizeAddressActivity(index.byAddress[normalized.toLowerCase()] ?? []); const explorerBase = chain138ExplorerBase(); const enrichedItems = items.map((row) => ({ ...row, chain138ExplorerUrl: chain138ExplorerTxUrl(row.txHash, explorerBase), roleLabel: row.role === 'from' ? 'debited' : 'credited', })); const onChainHint = total === 0 && cfg.addressActivityRegistry ? { registry: cfg.addressActivityRegistry, query: 'GET /v2/api?chainid=1&module=chain138mirror&action=participantlogs&address=' + normalized, } : null; return ok({ total, summary, items: enrichedItems, onChainHint, chain138Explorer: { base: explorerBase, txUrlTemplate: `${explorerBase}/tx/{txHash}` }, }); } if (module === 'proxy' && action === 'eth_blockNumber') { const provider = new ethers.JsonRpcProvider(cfg.chain138Rpc); const block = await provider.getBlockNumber(); return ok(`0x${block.toString(16)}`); } return err(`Unsupported module/action for chainid=138: ${module}/${action}`); } function publicBaseFromQuery(q: Request['query']): string { const override = String(q.indexerBase ?? process.env.CHECKPOINT_PAYLOAD_PUBLIC_URL ?? ''); if (override.startsWith('http')) return override.replace(/\/v1\/payload\/?$/, '').replace(/\/$/, ''); return `http://127.0.0.1:${cfg.port}`; } async function handleMainnetAttestation( action: string, q: Request['query'], apiKey: string ): Promise<{ status: string; message: string; result: unknown }> { const registry = cfg.addressActivityRegistry; const mirror = cfg.legacyMirror; const address = String(q.address ?? ''); if (action === 'contracts') { const explorerBase = chain138ExplorerBase(); return ok({ transactionMirror: mirror, addressActivityRegistry: registry || null, checkpointHub: cfg.checkpointProxy || null, chain138Explorer: { base: explorerBase, txUrlTemplate: `${explorerBase}/tx/{txHash}`, }, eventTopics: { TransactionMirrored: '0xc25ce5062c7e42c68fa21fe088d21e609cc0c61b8bec3180681363bb5cf02a9e', ParticipantCredited: '0x853ebad0824f583553bcb4360a0df947d39654e86aba8d7c0625499cbf09f0d5', ParticipantDebited: '0xf122cad7c8ada37ddd6b6ddca0982b9f7be689cde15eedfd767cc207fa7dc2c8', }, }); } if (action === 'participantlogs') { if (!registry && !cfg.addressActivityRegistryV2) { return err('ADDRESS_ACTIVITY_REGISTRY_MAINNET or V2 not configured'); } if (!/^0x[a-fA-F0-9]{40}$/.test(address)) return err('Invalid address'); if (!apiKey) return err('ETHERSCAN_API_KEY required for mainnet log proxy'); const topic = padTopicAddress(address); const params = new URLSearchParams({ fromBlock: String(q.fromBlock ?? '0'), toBlock: String(q.toBlock ?? 'latest'), }); let credited = { status: '0', message: 'No records', result: [] as unknown[] }; let debited = { status: '0', message: 'No records', result: [] as unknown[] }; if (registry) { const regParams = new URLSearchParams([...params.entries(), ['address', registry]]); credited = await etherscanV2GetLogs( new URLSearchParams([ ...regParams.entries(), ['topic0', '0x853ebad0824f583553bcb4360a0df947d39654e86aba8d7c0625499cbf09f0d5'], ['topic2', topic], ]), apiKey ); debited = await etherscanV2GetLogs( new URLSearchParams([ ...regParams.entries(), ['topic0', '0xf122cad7c8ada37ddd6b6ddca0982b9f7be689cde15eedfd767cc207fa7dc2c8'], ['topic3', topic], ]), apiKey ); } let surfaceNotify: { status: string; result: unknown[]; decoded: ReturnType; } | null = null; if (cfg.participantSurface) { const surfaceRaw = await etherscanV2GetLogs( new URLSearchParams([ ...params.entries(), ['address', cfg.participantSurface], ['topic0', CHAIN138_ACTIVITY_NOTIFIED_TOPIC0], ['topic1', topic], ]), apiKey ); const surfaceEnv = surfaceRaw as { status: string; result: unknown[] }; surfaceNotify = { status: surfaceEnv.status, result: surfaceEnv.result ?? [], decoded: decodeChain138ActivityNotifiedLogs( surfaceEnv as { status: string; result: Array<{ topics?: string[]; transactionHash?: string; blockNumber?: string; logIndex?: string; data?: string; }>; }, chain138ExplorerBase() ), }; } const explorerBase = chain138ExplorerBase(); const index = buildAddressIndex(cfg.batchPayloadDir); const normalized = normalizeAddress(address); const { total: batchTotal, items: batchItems } = getAddressTransactions(index, normalized, 200, 0); const batchIndexedActivity = batchItems.map((row) => ({ ...row, chain138ExplorerUrl: chain138ExplorerTxUrl(row.txHash, explorerBase), roleLabel: row.role === 'from' ? 'debited' : 'credited', })); const debtorTxHashes = [ ...new Set( batchItems.filter((r) => r.role === 'from').map((r) => r.txHash.toLowerCase()) ), ]; let isoV2: { status: string; result: unknown[]; decoded: ReturnType } | null = null; if (cfg.addressActivityRegistryV2) { const v2raw = await etherscanV2GetLogs( new URLSearchParams([ ...params.entries(), ['address', cfg.addressActivityRegistryV2], ['topic0', PAYMENT_ATTESTED_TOPIC0], ['topic3', topic], ]), apiKey ); const v2env = v2raw as { status: string; result: unknown[] }; const decodedCreditor = decodePaymentAttestedLogs( v2env as { status: string; result: Array<{ topics?: string[]; transactionHash?: string; blockNumber?: string; logIndex?: string; data?: string }> }, explorerBase ); const decodedDebtor: ReturnType = []; for (const txHash of debtorTxHashes) { const byTx = await etherscanV2GetLogs( new URLSearchParams([ ...params.entries(), ['address', cfg.addressActivityRegistryV2], ['topic0', PAYMENT_ATTESTED_TOPIC0], ['topic1', txHash], ]), apiKey ); decodedDebtor.push( ...decodePaymentAttestedLogs( byTx as { status: string; result: Array<{ topics?: string[]; transactionHash?: string; blockNumber?: string; logIndex?: string; data?: string }> }, explorerBase ).filter((d) => d.debtor.toLowerCase() === normalized.toLowerCase()) ); } const merged = new Map(); for (const row of [...decodedCreditor, ...decodedDebtor]) { merged.set(`${row.chain138TxHash}:${row.mainnetTxHash}`, row); } isoV2 = { status: v2env.status, result: v2env.result ?? [], decoded: [...merged.values()], }; } return ok({ participant: normalized, registry: registry || null, registryV2: cfg.addressActivityRegistryV2 || null, participantSurface: cfg.participantSurface || null, credited: enrichRegistryLogsEnvelope(credited), debited: enrichRegistryLogsEnvelope(debited), surfaceNotifications: surfaceNotify, iso20022V2: isoV2, batchIndexedActivity: { total: batchTotal, items: batchIndexedActivity, }, chain138Explorer: { base: explorerBase, txUrlTemplate: `${explorerBase}/tx/{txHash}`, }, topicIndex: { ParticipantCredited: { chain138TxHash: 'topic1', participant: 'topic2', counterparty: 'topic3' }, ParticipantDebited: { chain138TxHash: 'topic1', counterparty: 'topic2', participant: 'topic3' }, PaymentAttested: { chain138TxHash: 'topic1', instructionId: 'topic2', creditor: 'topic3', debtor: 'data' }, Chain138ActivityNotified: { participant: 'topic1', chain138TxHash: 'topic2', instructionId: 'topic3', }, }, etherscanVisibility: { transactionsTab: 'Incoming 0 ETH txs to participant (CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH=1, default on) — calldata chain138Attestation(bytes32,uint8,uint64)', internalTxnsTab: 'Chain138ParticipantSurface wallet touch per sender and receiver', eventsOnSurfaceContract: 'surfaceNotifications.decoded[] — filter topic1=participant on surface contract', registryV1Logs: 'credited/debited.decoded[] — ParticipantCredited + ParticipantDebited', }, note: 'Each Chain 138 payment surfaces twice on mainnet: debited (from) + credited (to). batchIndexedActivity.items[] lists both roles with chain138TxHash. Full pacs.008 XML in batch payloads.', }); } if (action === 'mirrorlogs') { if (!/^0x[a-fA-F0-9]{64}$/.test(String(q.txhash ?? ''))) return err('Invalid txhash'); if (!apiKey) return err('ETHERSCAN_API_KEY required'); const txHash = String(q.txhash); const params = new URLSearchParams({ address: mirror, fromBlock: String(q.fromBlock ?? '0'), toBlock: String(q.toBlock ?? 'latest'), topic0: '0xc25ce5062c7e42c68fa21fe088d21e609cc0c61b8bec3180681363bb5cf02a9e', topic1: txHash, }); const logs = await etherscanV2GetLogs(params, apiKey); return ok(logs); } return err(`Unsupported chain138mirror action: ${action}`); }