Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m12s
CI/CD Pipeline / Security Scanning (push) Successful in 2m38s
CI/CD Pipeline / Lint and Format (push) Failing after 40s
CI/CD Pipeline / Terraform Validation (push) Failing after 21s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 24s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 24s
Validation / validate-genesis (push) Successful in 26s
Validation / validate-terraform (push) Failing after 21s
Validation / validate-kubernetes (push) Failing after 9s
Validation / validate-smart-contracts (push) Failing after 9s
Validation / validate-security (push) Failing after 1m7s
Validation / validate-documentation (push) Failing after 15s
Resolve etherscanLinks from batch metadata and optional mainnet log scans; serve a fast local-batch attestation path for the explorer CT. Co-authored-by: Cursor <cursoragent@cursor.com>
456 lines
17 KiB
TypeScript
456 lines
17 KiB
TypeScript
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<Record<string, unknown>> };
|
|
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<typeof decodeChain138ActivityNotifiedLogs>;
|
|
} | 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<typeof decodePaymentAttestedLogs> } | 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<typeof decodePaymentAttestedLogs> = [];
|
|
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<string, (typeof decodedCreditor)[0]>();
|
|
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}`);
|
|
}
|