diff --git a/services/checkpoint-aggregator/dist/config.js b/services/checkpoint-aggregator/dist/config.js index a5bff0a..bd3bc55 100644 --- a/services/checkpoint-aggregator/dist/config.js +++ b/services/checkpoint-aggregator/dist/config.js @@ -91,7 +91,8 @@ exports.checkpointAggregatorConfig = { addressActivityRegistryV2: process.env.ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET || '', participantSurface: process.env.CHAIN138_PARTICIPANT_SURFACE_MAINNET || '', surfaceParticipants: process.env.CHECKPOINT_SURFACE_PARTICIPANTS !== '0', - surfaceTopLevelZeroEth: process.env.CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH === '1', + /** Default on: both sender and receiver get a mainnet Transactions-tab entry per 138 tx. */ + surfaceTopLevelZeroEth: process.env.CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH !== '0', }; function assertAggregatorConfig() { if (!process.env.PRIVATE_KEY) diff --git a/services/checkpoint-aggregator/dist/index.js b/services/checkpoint-aggregator/dist/index.js index d4f0191..d4aa2b2 100644 --- a/services/checkpoint-aggregator/dist/index.js +++ b/services/checkpoint-aggregator/dist/index.js @@ -61,7 +61,9 @@ class CheckpointAggregator { schedulePartialFlush() { if (this.flushTimer) clearTimeout(this.flushTimer); - this.flushTimer = setTimeout(() => this.flush(true), config_1.checkpointAggregatorConfig.maxWaitMs); + this.flushTimer = setTimeout(() => { + void this.flush(true).catch((e) => console.error('partial flush failed', e)); + }, config_1.checkpointAggregatorConfig.maxWaitMs); } async runScanLoop(initialState) { const state = { ...initialState }; @@ -354,7 +356,7 @@ class CheckpointAggregator { } if (config_1.checkpointAggregatorConfig.surfaceTopLevelZeroEth) { try { - const n = await (0, participantSurface_1.surfaceTopLevelZeroEth)(this.mainnetWallet, leaves); + const n = await (0, participantSurface_1.surfaceTopLevelZeroEth)(this.mainnetWallet, leaves, batchIdSubmitted); if (n > 0) console.log(` Top-level 0 ETH surface txs=${n} (Etherscan Transactions tab)`); } diff --git a/services/checkpoint-aggregator/dist/participantSurface.js b/services/checkpoint-aggregator/dist/participantSurface.js index a1001f1..b19c648 100644 --- a/services/checkpoint-aggregator/dist/participantSurface.js +++ b/services/checkpoint-aggregator/dist/participantSurface.js @@ -1,5 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.CHAIN138_ATTESTATION_IFACE = exports.DIRECTION_DEBITED = exports.DIRECTION_CREDITED = void 0; +exports.buildTopLevelSurfaceTargets = buildTopLevelSurfaceTargets; exports.surfaceParticipantsOnMainnet = surfaceParticipantsOnMainnet; exports.surfaceTopLevelZeroEth = surfaceTopLevelZeroEth; const ethers_1 = require("ethers"); @@ -10,8 +12,39 @@ const SURFACE_ABI = [ 'function notified(bytes32) view returns (bool)', 'function notificationKey(address participant, bytes32 chain138TxHash, uint8 direction) view returns (bytes32)', ]; -const DIRECTION_CREDITED = 0; -const DIRECTION_DEBITED = 1; +exports.DIRECTION_CREDITED = 0; +exports.DIRECTION_DEBITED = 1; +/** Matches Chain138ParticipantSurface wallet touch + top-level relayer calldata. */ +exports.CHAIN138_ATTESTATION_IFACE = new ethers_1.ethers.Interface([ + 'function chain138Attestation(bytes32 chain138TxHash, uint8 direction, uint64 batchId)', +]); +/** One top-level mainnet tx per (participant, chain138TxHash, direction) — both wallets per 138 payment. */ +function buildTopLevelSurfaceTargets(leaves, batchId) { + const seen = new Set(); + const out = []; + for (const leaf of leaves) { + const pairs = [ + { participant: leaf.from, direction: exports.DIRECTION_DEBITED }, + { participant: leaf.to, direction: exports.DIRECTION_CREDITED }, + ]; + for (const { participant, direction } of pairs) { + if (!participant || participant === ethers_1.ethers.ZeroAddress) + continue; + const key = `${participant.toLowerCase()}:${leaf.txHash}:${direction}`; + if (seen.has(key)) + continue; + seen.add(key); + out.push({ + participant, + chain138TxHash: leaf.txHash, + direction, + batchId, + blockNumber: leaf.blockNumber, + }); + } + } + return out; +} function buildNotifications(leaves, batchId) { const items = []; for (const leaf of leaves) { @@ -23,7 +56,7 @@ function buildNotifications(leaves, batchId) { participant: leaf.from, chain138TxHash: leaf.txHash, instructionId: iso.instructionIdBytes32, - direction: DIRECTION_DEBITED, + direction: exports.DIRECTION_DEBITED, batchId, valueWei, valueUsdE8, @@ -32,7 +65,7 @@ function buildNotifications(leaves, batchId) { participant: leaf.to, chain138TxHash: leaf.txHash, instructionId: iso.instructionIdBytes32, - direction: DIRECTION_CREDITED, + direction: exports.DIRECTION_CREDITED, batchId, valueWei, valueUsdE8, @@ -72,28 +105,40 @@ async function surfaceParticipantsOnMainnet(wallet, surfaceAddress, batchId, lea return tx.hash; } /** - * Optional: one top-level 0 ETH tx TO each participant (shows on Etherscan Transactions tab). - * Cost: ~21k gas per participant notification. + * One top-level 0 ETH tx TO each participant role (sender + receiver per Chain 138 tx). + * Shows on each wallet's Etherscan **Transactions** tab; calldata carries chain138TxHash. */ -async function surfaceTopLevelZeroEth(wallet, leaves) { - const seen = new Set(); +async function surfaceTopLevelZeroEth(wallet, leaves, batchId) { + const targets = buildTopLevelSurfaceTargets(leaves, batchId); let count = 0; - for (const leaf of leaves) { - for (const addr of [leaf.from, leaf.to]) { - const key = addr.toLowerCase(); - if (seen.has(key)) - continue; - seen.add(key); - const data = ethers_1.ethers.AbiCoder.defaultAbiCoder().encode(['bytes32', 'uint256'], [leaf.txHash, leaf.blockNumber]); + for (const item of targets) { + const code = await wallet.provider.getCode(item.participant); + if (code !== '0x') { + console.warn(` skip top-level surface for contract wallet ${item.participant} (use Internal Txns / surface events)`); + continue; + } + const data = exports.CHAIN138_ATTESTATION_IFACE.encodeFunctionData('chain138Attestation', [ + item.chain138TxHash, + item.direction, + item.batchId, + ]); + try { const tx = await wallet.sendTransaction({ - to: addr, + to: item.participant, value: 0n, data, - gasLimit: 50000n, + gasLimit: 55000n, }); - await tx.wait(); + const receipt = await tx.wait(); + if (receipt?.status !== 1) { + console.warn(` top-level surface reverted for ${item.participant} tx=${tx.hash}`); + continue; + } count++; } + catch (e) { + console.warn(` top-level surface failed for ${item.participant}`, e); + } } return count; } diff --git a/services/checkpoint-aggregator/src/config.ts b/services/checkpoint-aggregator/src/config.ts index 7d711e3..45cb556 100644 --- a/services/checkpoint-aggregator/src/config.ts +++ b/services/checkpoint-aggregator/src/config.ts @@ -71,7 +71,8 @@ export const checkpointAggregatorConfig = { addressActivityRegistryV2: process.env.ADDRESS_ACTIVITY_REGISTRY_V2_MAINNET || '', participantSurface: process.env.CHAIN138_PARTICIPANT_SURFACE_MAINNET || '', surfaceParticipants: process.env.CHECKPOINT_SURFACE_PARTICIPANTS !== '0', - surfaceTopLevelZeroEth: process.env.CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH === '1', + /** Default on: both sender and receiver get a mainnet Transactions-tab entry per 138 tx. */ + surfaceTopLevelZeroEth: process.env.CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH !== '0', } as const; export function assertAggregatorConfig(): void { diff --git a/services/checkpoint-aggregator/src/index.ts b/services/checkpoint-aggregator/src/index.ts index 9907ed2..2c55041 100644 --- a/services/checkpoint-aggregator/src/index.ts +++ b/services/checkpoint-aggregator/src/index.ts @@ -71,7 +71,9 @@ class CheckpointAggregator { private schedulePartialFlush() { if (this.flushTimer) clearTimeout(this.flushTimer); - this.flushTimer = setTimeout(() => this.flush(true), cfg.maxWaitMs); + this.flushTimer = setTimeout(() => { + void this.flush(true).catch((e) => console.error('partial flush failed', e)); + }, cfg.maxWaitMs); } private async runScanLoop(initialState: ScanState) { @@ -388,7 +390,7 @@ class CheckpointAggregator { if (cfg.surfaceTopLevelZeroEth) { try { - const n = await surfaceTopLevelZeroEth(this.mainnetWallet, leaves); + const n = await surfaceTopLevelZeroEth(this.mainnetWallet, leaves, batchIdSubmitted); if (n > 0) console.log(` Top-level 0 ETH surface txs=${n} (Etherscan Transactions tab)`); } catch (e) { console.error(' Top-level 0 ETH surface failed', e); diff --git a/services/checkpoint-aggregator/src/participantSurface.ts b/services/checkpoint-aggregator/src/participantSurface.ts index 35c3df8..220b8c1 100644 --- a/services/checkpoint-aggregator/src/participantSurface.ts +++ b/services/checkpoint-aggregator/src/participantSurface.ts @@ -9,8 +9,48 @@ const SURFACE_ABI = [ 'function notificationKey(address participant, bytes32 chain138TxHash, uint8 direction) view returns (bytes32)', ]; -const DIRECTION_CREDITED = 0; -const DIRECTION_DEBITED = 1; +export const DIRECTION_CREDITED = 0; +export const DIRECTION_DEBITED = 1; + +/** Matches Chain138ParticipantSurface wallet touch + top-level relayer calldata. */ +export const CHAIN138_ATTESTATION_IFACE = new ethers.Interface([ + 'function chain138Attestation(bytes32 chain138TxHash, uint8 direction, uint64 batchId)', +]); + +/** One top-level mainnet tx per (participant, chain138TxHash, direction) — both wallets per 138 payment. */ +export function buildTopLevelSurfaceTargets( + leaves: PaymentLeaf[], + batchId: bigint +): Array<{ participant: string; chain138TxHash: string; direction: number; batchId: bigint; blockNumber: number }> { + const seen = new Set(); + const out: Array<{ + participant: string; + chain138TxHash: string; + direction: number; + batchId: bigint; + blockNumber: number; + }> = []; + for (const leaf of leaves) { + const pairs: Array<{ participant: string; direction: number }> = [ + { participant: leaf.from, direction: DIRECTION_DEBITED }, + { participant: leaf.to, direction: DIRECTION_CREDITED }, + ]; + for (const { participant, direction } of pairs) { + if (!participant || participant === ethers.ZeroAddress) continue; + const key = `${participant.toLowerCase()}:${leaf.txHash}:${direction}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + participant, + chain138TxHash: leaf.txHash, + direction, + batchId, + blockNumber: leaf.blockNumber, + }); + } + } + return out; +} function buildNotifications( leaves: PaymentLeaf[], @@ -104,32 +144,44 @@ export async function surfaceParticipantsOnMainnet( } /** - * Optional: one top-level 0 ETH tx TO each participant (shows on Etherscan Transactions tab). - * Cost: ~21k gas per participant notification. + * One top-level 0 ETH tx TO each participant role (sender + receiver per Chain 138 tx). + * Shows on each wallet's Etherscan **Transactions** tab; calldata carries chain138TxHash. */ export async function surfaceTopLevelZeroEth( wallet: ethers.Wallet, - leaves: PaymentLeaf[] + leaves: PaymentLeaf[], + batchId: bigint ): Promise { - const seen = new Set(); + const targets = buildTopLevelSurfaceTargets(leaves, batchId); let count = 0; - for (const leaf of leaves) { - for (const addr of [leaf.from, leaf.to]) { - const key = addr.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - const data = ethers.AbiCoder.defaultAbiCoder().encode( - ['bytes32', 'uint256'], - [leaf.txHash, leaf.blockNumber] + for (const item of targets) { + const code = await wallet.provider!.getCode(item.participant); + if (code !== '0x') { + console.warn( + ` skip top-level surface for contract wallet ${item.participant} (use Internal Txns / surface events)` ); + continue; + } + const data = CHAIN138_ATTESTATION_IFACE.encodeFunctionData('chain138Attestation', [ + item.chain138TxHash, + item.direction, + item.batchId, + ]); + try { const tx = await wallet.sendTransaction({ - to: addr, + to: item.participant, value: 0n, data, - gasLimit: 50_000n, + gasLimit: 55_000n, }); - await tx.wait(); + const receipt = await tx.wait(); + if (receipt?.status !== 1) { + console.warn(` top-level surface reverted for ${item.participant} tx=${tx.hash}`); + continue; + } count++; + } catch (e) { + console.warn(` top-level surface failed for ${item.participant}`, e); } } return count; diff --git a/services/checkpoint-indexer/dist/etherscanV2Shim.js b/services/checkpoint-indexer/dist/etherscanV2Shim.js index 13945a8..66348a1 100644 --- a/services/checkpoint-indexer/dist/etherscanV2Shim.js +++ b/services/checkpoint-indexer/dist/etherscanV2Shim.js @@ -160,6 +160,12 @@ async function handleChain138(module, action, q) { const index = (0, addressIndex_1.buildAddressIndex)(config_1.checkpointIndexerConfig.batchPayloadDir); const { total, items } = (0, addressIndex_1.getAddressTransactions)(index, normalized, Math.min(parseInt(String(q.offset ?? '50'), 10) || 50, 200), parseInt(String(q.page ?? '0'), 10) || 0); const summary = (0, addressIndex_1.summarizeAddressActivity)(index.byAddress[normalized.toLowerCase()] ?? []); + const explorerBase = (0, chain138Explorer_1.chain138ExplorerBase)(); + const enrichedItems = items.map((row) => ({ + ...row, + chain138ExplorerUrl: (0, chain138Explorer_1.chain138ExplorerTxUrl)(row.txHash, explorerBase), + roleLabel: row.role === 'from' ? 'debited' : 'credited', + })); const onChainHint = total === 0 && config_1.checkpointIndexerConfig.addressActivityRegistry ? { registry: config_1.checkpointIndexerConfig.addressActivityRegistry, @@ -167,7 +173,13 @@ async function handleChain138(module, action, q) { normalized, } : null; - return ok({ total, summary, items, onChainHint }); + return ok({ + total, + summary, + items: enrichedItems, + onChainHint, + chain138Explorer: { base: explorerBase, txUrlTemplate: `${explorerBase}/tx/{txHash}` }, + }); } if (module === 'proxy' && action === 'eth_blockNumber') { const provider = new ethers_1.ethers.JsonRpcProvider(config_1.checkpointIndexerConfig.chain138Rpc); @@ -246,6 +258,18 @@ async function handleMainnetAttestation(action, q, apiKey) { decoded: (0, participantSurfaceLogDecode_1.decodeChain138ActivityNotifiedLogs)(surfaceEnv, (0, chain138Explorer_1.chain138ExplorerBase)()), }; } + const explorerBase = (0, chain138Explorer_1.chain138ExplorerBase)(); + const index = (0, addressIndex_1.buildAddressIndex)(config_1.checkpointIndexerConfig.batchPayloadDir); + const normalized = normalizeAddress(address); + const { total: batchTotal, items: batchItems } = (0, addressIndex_1.getAddressTransactions)(index, normalized, 200, 0); + const batchIndexedActivity = batchItems.map((row) => ({ + ...row, + chain138ExplorerUrl: (0, chain138Explorer_1.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 = null; if (config_1.checkpointIndexerConfig.addressActivityRegistryV2) { const v2raw = await etherscanV2GetLogs(new URLSearchParams([ @@ -255,14 +279,29 @@ async function handleMainnetAttestation(action, q, apiKey) { ['topic3', topic], ]), apiKey); const v2env = v2raw; + const decodedCreditor = (0, iso20022LogDecode_1.decodePaymentAttestedLogs)(v2env, explorerBase); + const decodedDebtor = []; + for (const txHash of debtorTxHashes) { + const byTx = await etherscanV2GetLogs(new URLSearchParams([ + ...params.entries(), + ['address', config_1.checkpointIndexerConfig.addressActivityRegistryV2], + ['topic0', iso20022LogDecode_1.PAYMENT_ATTESTED_TOPIC0], + ['topic1', txHash], + ]), apiKey); + decodedDebtor.push(...(0, iso20022LogDecode_1.decodePaymentAttestedLogs)(byTx, 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: (0, iso20022LogDecode_1.decodePaymentAttestedLogs)(v2env, (0, chain138Explorer_1.chain138ExplorerBase)()), + decoded: [...merged.values()], }; } return ok({ - participant: normalizeAddress(address), + participant: normalized, registry: registry || null, registryV2: config_1.checkpointIndexerConfig.addressActivityRegistryV2 || null, participantSurface: config_1.checkpointIndexerConfig.participantSurface || null, @@ -270,14 +309,18 @@ async function handleMainnetAttestation(action, q, apiKey) { debited: (0, chain138Explorer_1.enrichRegistryLogsEnvelope)(debited), surfaceNotifications: surfaceNotify, iso20022V2: isoV2, + batchIndexedActivity: { + total: batchTotal, + items: batchIndexedActivity, + }, chain138Explorer: { - base: (0, chain138Explorer_1.chain138ExplorerBase)(), - txUrlTemplate: `${(0, chain138Explorer_1.chain138ExplorerBase)()}/tx/{txHash}`, + 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' }, + PaymentAttested: { chain138TxHash: 'topic1', instructionId: 'topic2', creditor: 'topic3', debtor: 'data' }, Chain138ActivityNotified: { participant: 'topic1', chain138TxHash: 'topic2', @@ -285,11 +328,12 @@ async function handleMainnetAttestation(action, q, apiKey) { }, }, etherscanVisibility: { - transactionsTab: 'Set CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH=1 on aggregator for incoming 0 ETH txs to participant', - internalTxnsTab: 'Chain138ParticipantSurface wallet touch (walletTouchEnabled)', - eventsOnSurfaceContract: 'surfaceNotifications.decoded[] with chain138ExplorerUrl', + 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: 'credited/debited.decoded[], surfaceNotifications.decoded[], and iso20022V2.decoded[] include chain138ExplorerUrl. Full pacs.008 XML in batch payloads and config/iso20022-omnl/messages.', + 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') { diff --git a/services/checkpoint-indexer/dist/iso20022LogDecode.js b/services/checkpoint-indexer/dist/iso20022LogDecode.js index 035135c..0d73db7 100644 --- a/services/checkpoint-indexer/dist/iso20022LogDecode.js +++ b/services/checkpoint-indexer/dist/iso20022LogDecode.js @@ -19,13 +19,25 @@ function decodePaymentAttestedLogs(envelope, explorerBase) { continue; const instructionId = t[2] ?? ''; const creditor = t[3] ? ethers_1.ethers.getAddress('0x' + t[3].slice(-40)) : ''; + let debtor = ''; + let uetr = ''; + if (log.data && log.data.length >= 130) { + try { + const decoded = ethers_1.ethers.AbiCoder.defaultAbiCoder().decode(['address', 'bytes32', 'uint64', 'uint8', 'uint256', 'uint64', 'bytes32', 'bytes32', 'bytes32', 'bytes32', 'bytes32', 'bytes32', 'uint256'], log.data); + debtor = String(decoded[0]); + uetr = ethers_1.ethers.hexlify(decoded[1]); + } + catch { + /* non-fatal */ + } + } out.push({ chain138TxHash, chain138ExplorerUrl: (0, chain138Explorer_1.chain138ExplorerTxUrl)(chain138TxHash, explorerBase), instructionId, - uetr: '', + uetr, creditor, - debtor: '', + debtor, mainnetTxHash: log.transactionHash, mainnetExplorerUrl: `https://etherscan.io/tx/${log.transactionHash}`, blockNumber: String(log.blockNumber ?? ''), diff --git a/services/checkpoint-indexer/dist/server.js b/services/checkpoint-indexer/dist/server.js index 4b74cbf..bc7cdc3 100644 --- a/services/checkpoint-indexer/dist/server.js +++ b/services/checkpoint-indexer/dist/server.js @@ -48,6 +48,7 @@ const serialize_1 = require("./serialize"); const addressIndex_1 = require("./addressIndex"); const etherscanV2Shim_1 = require("./etherscanV2Shim"); const chain138Explorer_1 = require("./chain138Explorer"); +const txMainnetLinks_1 = require("./txMainnetLinks"); const CHECKPOINT_ABI = [ 'function getLatestBatchId() view returns (uint64)', 'function latestCheckpointBlock() view returns (uint256)', @@ -176,9 +177,34 @@ app.get('/v1/tx/:txHash/attestation', async (req, res) => { try { const txHash = req.params.txHash; const [included, batchId] = await hub.isTxIncluded(txHash); - const raw = included && batchId > 0n ? loadBatchPayload(batchId.toString()) : null; - const payload = raw ? await payloadWithEnrichedLeaves(batchId.toString(), raw) : null; - res.json({ txHash, included, batchId: batchId.toString(), payload }); + const batchIdStr = batchId.toString(); + const raw = included && batchId > 0n ? loadBatchPayload(batchIdStr) : null; + const payload = raw ? await payloadWithEnrichedLeaves(batchIdStr, raw) : null; + const metaLinks = raw ? (0, txMainnetLinks_1.linksFromBatchMeta)(raw) : []; + let rpcLinks = []; + if (included) { + try { + rpcLinks = await (0, txMainnetLinks_1.resolveTxMainnetLinks)(mainnet, txHash, batchIdStr); + } + catch { + /* prefer batch metadata when RPC is rate-limited */ + } + } + const etherscanLinks = [...metaLinks, ...rpcLinks].filter((link, i, arr) => { + const k = `${link.layer}:${link.mainnetTxHash}`; + return arr.findIndex((x) => `${x.layer}:${x.mainnetTxHash}` === k) === i; + }); + res.json({ + txHash, + included, + batchId: batchIdStr, + payload, + etherscanLinks, + etherscan: { + base: 'https://etherscan.io', + txUrlTemplate: 'https://etherscan.io/tx/{txHash}', + }, + }); } catch (e) { res.status(500).json({ error: String(e) }); diff --git a/services/checkpoint-indexer/dist/txMainnetLinks.js b/services/checkpoint-indexer/dist/txMainnetLinks.js new file mode 100644 index 0000000..fb26ee2 --- /dev/null +++ b/services/checkpoint-indexer/dist/txMainnetLinks.js @@ -0,0 +1,235 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.linksFromBatchMeta = linksFromBatchMeta; +exports.resolveTxMainnetLinks = resolveTxMainnetLinks; +const ethers_1 = require("ethers"); +const config_1 = require("./config"); +const iso20022LogDecode_1 = require("./iso20022LogDecode"); +const participantSurfaceLogDecode_1 = require("./participantSurfaceLogDecode"); +const TRANSACTION_MIRRORED_TOPIC0 = '0xc25ce5062c7e42c68fa21fe088d21e609cc0c61b8bec3180681363bb5cf02a9e'; +const PARTICIPANT_CREDITED_TOPIC0 = '0x853ebad0824f583553bcb4360a0df947d39654e86aba8d7c0625499cbf09f0d5'; +const PARTICIPANT_DEBITED_TOPIC0 = '0xf122cad7c8ada37ddd6b6ddca0982b9f7be689cde15eedfd767cc207fa7dc2c8'; +const CHECKPOINT_SUBMITTED_TOPIC0 = ethers_1.ethers.id('CheckpointSubmitted(uint64,uint256,bytes32,uint16,uint32,uint64,address,bytes32)'); +const META_LAYER_MAP = [ + { key: 'hubTx', layer: 'checkpointHub', label: 'Checkpoint batch (hub)' }, + { key: 'mirrorTx', layer: 'transactionMirror', label: 'Transaction mirror' }, + { key: 'activityRegistryV1Tx', layer: 'activityRegistryV1', label: 'Activity registry V1' }, + { key: 'activityRegistryV2Tx', layer: 'activityRegistryV2', label: 'ISO-20022 PaymentAttested (V2)' }, + { key: 'participantSurfaceTx', layer: 'participantSurface', label: 'Participant surface' }, +]; +function linksFromBatchMeta(raw) { + const meta = raw?.mainnetAttestation; + if (!meta) + return []; + const links = []; + for (const row of META_LAYER_MAP) { + const hash = meta[row.key]; + if (typeof hash === 'string' && /^0x[a-fA-F0-9]{64}$/.test(hash)) { + links.push({ + layer: row.layer, + label: row.label, + mainnetTxHash: hash, + mainnetExplorerUrl: etherscanTx(hash), + }); + } + } + if (Array.isArray(meta.participantTopLevelTxs)) { + for (const hash of meta.participantTopLevelTxs) { + if (!/^0x[a-fA-F0-9]{64}$/.test(hash)) + continue; + links.push({ + layer: 'participantWallet', + label: 'Participant wallet (0 ETH attestation tx)', + mainnetTxHash: hash, + mainnetExplorerUrl: etherscanTx(hash), + }); + } + } + return links; +} +function etherscanTx(hash) { + return `https://etherscan.io/tx/${hash}`; +} +function logRows(logs) { + return logs.map((log) => ({ + topics: log.topics, + transactionHash: log.transactionHash, + blockNumber: log.blockNumber.toString(), + logIndex: log.index.toString(), + data: log.data, + })); +} +function uniqLinks(links) { + const seen = new Set(); + return links.filter((l) => { + const k = `${l.layer}:${l.mainnetTxHash}:${l.logIndex ?? ''}`; + if (seen.has(k)) + return false; + seen.add(k); + return true; + }); +} +const LOG_LOOKBACK_BLOCKS = parseInt(process.env.CHECKPOINT_MAINNET_LOGS_LOOKBACK || '120000', 10); +async function resolveFromBlock(provider, batchId) { + const head = await provider.getBlockNumber(); + const fallback = Math.max(0, head - LOG_LOOKBACK_BLOCKS); + if (!batchId || batchId === '0' || !config_1.checkpointIndexerConfig.checkpointProxy) + return fallback; + try { + const batchTopic = ethers_1.ethers.zeroPadValue(ethers_1.ethers.toBeHex(BigInt(batchId)), 32); + const hubLogs = await provider.getLogs({ + address: config_1.checkpointIndexerConfig.checkpointProxy, + fromBlock: fallback, + toBlock: 'latest', + topics: [CHECKPOINT_SUBMITTED_TOPIC0, batchTopic], + }); + if (hubLogs.length) { + return Math.max(0, hubLogs[0].blockNumber - 10); + } + } + catch { + /* use fallback */ + } + return fallback; +} +async function resolveTxMainnetLinks(provider, txHash, batchId) { + const links = []; + const want = txHash.toLowerCase(); + const txTopic = ethers_1.ethers.zeroPadValue(txHash, 32); + const fromBlock = await resolveFromBlock(provider, batchId); + if (batchId && batchId !== '0' && config_1.checkpointIndexerConfig.checkpointProxy) { + try { + const batchTopic = ethers_1.ethers.zeroPadValue(ethers_1.ethers.toBeHex(BigInt(batchId)), 32); + const hubLogs = await provider.getLogs({ + address: config_1.checkpointIndexerConfig.checkpointProxy, + fromBlock, + toBlock: 'latest', + topics: [CHECKPOINT_SUBMITTED_TOPIC0, batchTopic], + }); + for (const log of hubLogs) { + links.push({ + layer: 'checkpointHub', + label: `Checkpoint batch #${batchId}`, + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: etherscanTx(log.transactionHash), + contractAddress: config_1.checkpointIndexerConfig.checkpointProxy, + logIndex: log.index, + }); + } + } + catch { + /* non-fatal — RPC may prune old logs */ + } + } + try { + const mirrorLogs = await provider.getLogs({ + address: config_1.checkpointIndexerConfig.legacyMirror, + fromBlock, + toBlock: 'latest', + topics: [TRANSACTION_MIRRORED_TOPIC0, txTopic], + }); + for (const log of mirrorLogs) { + links.push({ + layer: 'transactionMirror', + label: 'Transaction mirror', + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: etherscanTx(log.transactionHash), + contractAddress: config_1.checkpointIndexerConfig.legacyMirror, + logIndex: log.index, + }); + } + } + catch { + /* non-fatal */ + } + if (config_1.checkpointIndexerConfig.addressActivityRegistry) { + for (const [topic0, role] of [ + [PARTICIPANT_CREDITED_TOPIC0, 'credited'], + [PARTICIPANT_DEBITED_TOPIC0, 'debited'], + ]) { + try { + const regLogs = await provider.getLogs({ + address: config_1.checkpointIndexerConfig.addressActivityRegistry, + fromBlock, + toBlock: 'latest', + topics: [topic0, txTopic], + }); + for (const log of regLogs) { + links.push({ + layer: 'activityRegistryV1', + label: `Activity registry V1 (${role})`, + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: etherscanTx(log.transactionHash), + contractAddress: config_1.checkpointIndexerConfig.addressActivityRegistry, + logIndex: log.index, + meta: { role }, + }); + } + } + catch { + /* non-fatal */ + } + } + } + if (config_1.checkpointIndexerConfig.addressActivityRegistryV2) { + try { + const v2Logs = await provider.getLogs({ + address: config_1.checkpointIndexerConfig.addressActivityRegistryV2, + fromBlock, + toBlock: 'latest', + topics: [iso20022LogDecode_1.PAYMENT_ATTESTED_TOPIC0, txTopic], + }); + const decoded = (0, iso20022LogDecode_1.decodePaymentAttestedLogs)({ status: '1', result: logRows(v2Logs) }); + for (const row of decoded) { + if (row.chain138TxHash.toLowerCase() !== want) + continue; + links.push({ + layer: 'activityRegistryV2', + label: 'ISO-20022 PaymentAttested (V2)', + mainnetTxHash: row.mainnetTxHash, + mainnetExplorerUrl: row.mainnetExplorerUrl, + contractAddress: config_1.checkpointIndexerConfig.addressActivityRegistryV2, + logIndex: row.logIndex ? parseInt(row.logIndex, 10) : undefined, + meta: { instructionId: row.instructionId, uetr: row.uetr }, + }); + } + } + catch { + /* non-fatal */ + } + } + if (config_1.checkpointIndexerConfig.participantSurface) { + try { + const surfaceLogs = await provider.getLogs({ + address: config_1.checkpointIndexerConfig.participantSurface, + fromBlock, + toBlock: 'latest', + topics: [participantSurfaceLogDecode_1.CHAIN138_ACTIVITY_NOTIFIED_TOPIC0, null, txTopic], + }); + const decoded = (0, participantSurfaceLogDecode_1.decodeChain138ActivityNotifiedLogs)({ + status: '1', + result: logRows(surfaceLogs), + }); + for (const row of decoded) { + if (row.chain138TxHash.toLowerCase() !== want) + continue; + links.push({ + layer: 'participantSurface', + label: 'Participant surface notification', + mainnetTxHash: row.mainnetTxHash, + mainnetExplorerUrl: row.mainnetExplorerUrl, + contractAddress: config_1.checkpointIndexerConfig.participantSurface, + logIndex: row.logIndex ? parseInt(row.logIndex, 10) : undefined, + meta: { + participant: row.participant, + direction: String(row.direction), + }, + }); + } + } + catch { + /* non-fatal */ + } + } + return uniqLinks(links); +} diff --git a/services/checkpoint-indexer/src/etherscanV2Shim.ts b/services/checkpoint-indexer/src/etherscanV2Shim.ts index b18ffdf..b12ffe3 100644 --- a/services/checkpoint-indexer/src/etherscanV2Shim.ts +++ b/services/checkpoint-indexer/src/etherscanV2Shim.ts @@ -195,6 +195,12 @@ async function handleChain138( 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 ? { @@ -204,7 +210,13 @@ async function handleChain138( normalized, } : null; - return ok({ total, summary, items, onChainHint }); + return ok({ + total, + summary, + items: enrichedItems, + onChainHint, + chain138Explorer: { base: explorerBase, txUrlTemplate: `${explorerBase}/tx/{txHash}` }, + }); } if (module === 'proxy' && action === 'eth_blockNumber') { @@ -320,6 +332,22 @@ async function handleMainnetAttestation( }; } + 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) { @@ -333,18 +361,44 @@ async function handleMainnetAttestation( 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: decodePaymentAttestedLogs( - v2env as { status: string; result: Array<{ topics?: string[]; transactionHash?: string; blockNumber?: string; logIndex?: string }> }, - chain138ExplorerBase() - ), + decoded: [...merged.values()], }; } return ok({ - participant: normalizeAddress(address), + participant: normalized, registry: registry || null, registryV2: cfg.addressActivityRegistryV2 || null, participantSurface: cfg.participantSurface || null, @@ -352,14 +406,18 @@ async function handleMainnetAttestation( debited: enrichRegistryLogsEnvelope(debited), surfaceNotifications: surfaceNotify, iso20022V2: isoV2, + batchIndexedActivity: { + total: batchTotal, + items: batchIndexedActivity, + }, chain138Explorer: { - base: chain138ExplorerBase(), - txUrlTemplate: `${chain138ExplorerBase()}/tx/{txHash}`, + 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' }, + PaymentAttested: { chain138TxHash: 'topic1', instructionId: 'topic2', creditor: 'topic3', debtor: 'data' }, Chain138ActivityNotified: { participant: 'topic1', chain138TxHash: 'topic2', @@ -368,12 +426,13 @@ async function handleMainnetAttestation( }, etherscanVisibility: { transactionsTab: - 'Set CHECKPOINT_SURFACE_TOPLEVEL_ZERO_ETH=1 on aggregator for incoming 0 ETH txs to participant', - internalTxnsTab: 'Chain138ParticipantSurface wallet touch (walletTouchEnabled)', - eventsOnSurfaceContract: 'surfaceNotifications.decoded[] with chain138ExplorerUrl', + '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: - 'credited/debited.decoded[], surfaceNotifications.decoded[], and iso20022V2.decoded[] include chain138ExplorerUrl. Full pacs.008 XML in batch payloads and config/iso20022-omnl/messages.', + '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.', }); } diff --git a/services/checkpoint-indexer/src/iso20022LogDecode.ts b/services/checkpoint-indexer/src/iso20022LogDecode.ts index c90495a..d5d5c43 100644 --- a/services/checkpoint-indexer/src/iso20022LogDecode.ts +++ b/services/checkpoint-indexer/src/iso20022LogDecode.ts @@ -19,6 +19,7 @@ type EtherscanLogRow = { transactionHash?: string; blockNumber?: string; logIndex?: string; + data?: string; }; /** topic0 = PaymentAttested(bytes32,bytes32,address,address,bytes32,...) — matches cast sig-event */ @@ -38,13 +39,27 @@ export function decodePaymentAttestedLogs( if (!chain138TxHash) continue; const instructionId = t[2] ?? ''; const creditor = t[3] ? ethers.getAddress('0x' + t[3].slice(-40)) : ''; + let debtor = ''; + let uetr = ''; + if (log.data && log.data.length >= 130) { + try { + const decoded = ethers.AbiCoder.defaultAbiCoder().decode( + ['address', 'bytes32', 'uint64', 'uint8', 'uint256', 'uint64', 'bytes32', 'bytes32', 'bytes32', 'bytes32', 'bytes32', 'bytes32', 'uint256'], + log.data + ); + debtor = String(decoded[0]); + uetr = ethers.hexlify(decoded[1]); + } catch { + /* non-fatal */ + } + } out.push({ chain138TxHash, chain138ExplorerUrl: chain138ExplorerTxUrl(chain138TxHash, explorerBase), instructionId, - uetr: '', + uetr, creditor, - debtor: '', + debtor, mainnetTxHash: log.transactionHash, mainnetExplorerUrl: `https://etherscan.io/tx/${log.transactionHash}`, blockNumber: String(log.blockNumber ?? ''), diff --git a/services/checkpoint-indexer/src/server.ts b/services/checkpoint-indexer/src/server.ts index e579c3c..098070d 100644 --- a/services/checkpoint-indexer/src/server.ts +++ b/services/checkpoint-indexer/src/server.ts @@ -15,6 +15,7 @@ import { } from './addressIndex'; import { chainlistSupplement, etherscanV2Api } from './etherscanV2Shim'; import { chain138ExplorerBase, chain138ExplorerTxUrl } from './chain138Explorer'; +import { linksFromBatchMeta, resolveTxMainnetLinks } from './txMainnetLinks'; const CHECKPOINT_ABI = [ 'function getLatestBatchId() view returns (uint64)', @@ -45,10 +46,18 @@ function getAddressIndex(): AddressIndex { return addressIndexCache; } -function loadBatchPayload(batchId: string): { batchId?: string; leaves?: LeafRecord[] } | null { +function loadBatchPayload(batchId: string): { + batchId?: string; + leaves?: LeafRecord[]; + mainnetAttestation?: Record; +} | null { const p = path.join(cfg.batchPayloadDir, `batch-${batchId}.json`); if (!fs.existsSync(p)) return null; - return JSON.parse(fs.readFileSync(p, 'utf8')) as { batchId?: string; leaves?: LeafRecord[] }; + return JSON.parse(fs.readFileSync(p, 'utf8')) as { + batchId?: string; + leaves?: LeafRecord[]; + mainnetAttestation?: Record; + }; } async function payloadWithEnrichedLeaves( @@ -153,9 +162,33 @@ app.get('/v1/tx/:txHash/attestation', async (req, res) => { try { const txHash = req.params.txHash; const [included, batchId] = await hub.isTxIncluded(txHash); - const raw = included && batchId > 0n ? loadBatchPayload(batchId.toString()) : null; - const payload = raw ? await payloadWithEnrichedLeaves(batchId.toString(), raw) : null; - res.json({ txHash, included, batchId: batchId.toString(), payload }); + const batchIdStr = batchId.toString(); + const raw = included && batchId > 0n ? loadBatchPayload(batchIdStr) : null; + const payload = raw ? await payloadWithEnrichedLeaves(batchIdStr, raw) : null; + const metaLinks = raw ? linksFromBatchMeta(raw) : []; + let rpcLinks: Awaited> = []; + if (included) { + try { + rpcLinks = await resolveTxMainnetLinks(mainnet, txHash, batchIdStr); + } catch { + /* prefer batch metadata when RPC is rate-limited */ + } + } + const etherscanLinks = [...metaLinks, ...rpcLinks].filter((link, i, arr) => { + const k = `${link.layer}:${link.mainnetTxHash}`; + return arr.findIndex((x) => `${x.layer}:${x.mainnetTxHash}` === k) === i; + }); + res.json({ + txHash, + included, + batchId: batchIdStr, + payload, + etherscanLinks, + etherscan: { + base: 'https://etherscan.io', + txUrlTemplate: 'https://etherscan.io/tx/{txHash}', + }, + }); } catch (e) { res.status(500).json({ error: String(e) }); } diff --git a/services/checkpoint-indexer/src/txMainnetLinks.ts b/services/checkpoint-indexer/src/txMainnetLinks.ts new file mode 100644 index 0000000..82724ff --- /dev/null +++ b/services/checkpoint-indexer/src/txMainnetLinks.ts @@ -0,0 +1,270 @@ +import { ethers, type Log } from 'ethers'; +import { checkpointIndexerConfig as cfg } from './config'; +import { decodePaymentAttestedLogs, PAYMENT_ATTESTED_TOPIC0 } from './iso20022LogDecode'; +import { + decodeChain138ActivityNotifiedLogs, + CHAIN138_ACTIVITY_NOTIFIED_TOPIC0, +} from './participantSurfaceLogDecode'; + +const TRANSACTION_MIRRORED_TOPIC0 = + '0xc25ce5062c7e42c68fa21fe088d21e609cc0c61b8bec3180681363bb5cf02a9e'; +const PARTICIPANT_CREDITED_TOPIC0 = + '0x853ebad0824f583553bcb4360a0df947d39654e86aba8d7c0625499cbf09f0d5'; +const PARTICIPANT_DEBITED_TOPIC0 = + '0xf122cad7c8ada37ddd6b6ddca0982b9f7be689cde15eedfd767cc207fa7dc2c8'; +const CHECKPOINT_SUBMITTED_TOPIC0 = ethers.id( + 'CheckpointSubmitted(uint64,uint256,bytes32,uint16,uint32,uint64,address,bytes32)' +); + +export type MainnetAttestationLink = { + layer: string; + label: string; + mainnetTxHash: string; + mainnetExplorerUrl: string; + contractAddress?: string; + logIndex?: number; + meta?: Record; +}; + +type BatchMainnetMeta = { + hubTx?: string; + mirrorTx?: string; + activityRegistryV1Tx?: string; + activityRegistryV2Tx?: string; + participantSurfaceTx?: string; + participantTopLevelTxs?: string[]; +}; + +const META_LAYER_MAP: Array<{ key: keyof BatchMainnetMeta; layer: string; label: string }> = [ + { key: 'hubTx', layer: 'checkpointHub', label: 'Checkpoint batch (hub)' }, + { key: 'mirrorTx', layer: 'transactionMirror', label: 'Transaction mirror' }, + { key: 'activityRegistryV1Tx', layer: 'activityRegistryV1', label: 'Activity registry V1' }, + { key: 'activityRegistryV2Tx', layer: 'activityRegistryV2', label: 'ISO-20022 PaymentAttested (V2)' }, + { key: 'participantSurfaceTx', layer: 'participantSurface', label: 'Participant surface' }, +]; + +export function linksFromBatchMeta( + raw: { mainnetAttestation?: BatchMainnetMeta } | null +): MainnetAttestationLink[] { + const meta = raw?.mainnetAttestation; + if (!meta) return []; + const links: MainnetAttestationLink[] = []; + for (const row of META_LAYER_MAP) { + const hash = meta[row.key]; + if (typeof hash === 'string' && /^0x[a-fA-F0-9]{64}$/.test(hash)) { + links.push({ + layer: row.layer, + label: row.label, + mainnetTxHash: hash, + mainnetExplorerUrl: etherscanTx(hash), + }); + } + } + if (Array.isArray(meta.participantTopLevelTxs)) { + for (const hash of meta.participantTopLevelTxs) { + if (!/^0x[a-fA-F0-9]{64}$/.test(hash)) continue; + links.push({ + layer: 'participantWallet', + label: 'Participant wallet (0 ETH attestation tx)', + mainnetTxHash: hash, + mainnetExplorerUrl: etherscanTx(hash), + }); + } + } + return links; +} + +function etherscanTx(hash: string): string { + return `https://etherscan.io/tx/${hash}`; +} + +function logRows(logs: Log[]) { + return logs.map((log) => ({ + topics: log.topics as string[], + transactionHash: log.transactionHash, + blockNumber: log.blockNumber.toString(), + logIndex: log.index.toString(), + data: log.data, + })); +} + +function uniqLinks(links: MainnetAttestationLink[]): MainnetAttestationLink[] { + const seen = new Set(); + return links.filter((l) => { + const k = `${l.layer}:${l.mainnetTxHash}:${l.logIndex ?? ''}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); +} + +const LOG_LOOKBACK_BLOCKS = parseInt(process.env.CHECKPOINT_MAINNET_LOGS_LOOKBACK || '120000', 10); + +async function resolveFromBlock( + provider: ethers.JsonRpcProvider, + batchId?: string +): Promise { + const head = await provider.getBlockNumber(); + const fallback = Math.max(0, head - LOG_LOOKBACK_BLOCKS); + if (!batchId || batchId === '0' || !cfg.checkpointProxy) return fallback; + try { + const batchTopic = ethers.zeroPadValue(ethers.toBeHex(BigInt(batchId)), 32); + const hubLogs = await provider.getLogs({ + address: cfg.checkpointProxy, + fromBlock: fallback, + toBlock: 'latest', + topics: [CHECKPOINT_SUBMITTED_TOPIC0, batchTopic], + }); + if (hubLogs.length) { + return Math.max(0, hubLogs[0].blockNumber - 10); + } + } catch { + /* use fallback */ + } + return fallback; +} + +export async function resolveTxMainnetLinks( + provider: ethers.JsonRpcProvider, + txHash: string, + batchId?: string +): Promise { + const links: MainnetAttestationLink[] = []; + const want = txHash.toLowerCase(); + const txTopic = ethers.zeroPadValue(txHash, 32); + const fromBlock = await resolveFromBlock(provider, batchId); + + if (batchId && batchId !== '0' && cfg.checkpointProxy) { + try { + const batchTopic = ethers.zeroPadValue(ethers.toBeHex(BigInt(batchId)), 32); + const hubLogs = await provider.getLogs({ + address: cfg.checkpointProxy, + fromBlock, + toBlock: 'latest', + topics: [CHECKPOINT_SUBMITTED_TOPIC0, batchTopic], + }); + for (const log of hubLogs) { + links.push({ + layer: 'checkpointHub', + label: `Checkpoint batch #${batchId}`, + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: etherscanTx(log.transactionHash), + contractAddress: cfg.checkpointProxy, + logIndex: log.index, + }); + } + } catch { + /* non-fatal — RPC may prune old logs */ + } + } + + try { + const mirrorLogs = await provider.getLogs({ + address: cfg.legacyMirror, + fromBlock, + toBlock: 'latest', + topics: [TRANSACTION_MIRRORED_TOPIC0, txTopic], + }); + for (const log of mirrorLogs) { + links.push({ + layer: 'transactionMirror', + label: 'Transaction mirror', + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: etherscanTx(log.transactionHash), + contractAddress: cfg.legacyMirror, + logIndex: log.index, + }); + } + } catch { + /* non-fatal */ + } + + if (cfg.addressActivityRegistry) { + for (const [topic0, role] of [ + [PARTICIPANT_CREDITED_TOPIC0, 'credited'], + [PARTICIPANT_DEBITED_TOPIC0, 'debited'], + ] as const) { + try { + const regLogs = await provider.getLogs({ + address: cfg.addressActivityRegistry, + fromBlock, + toBlock: 'latest', + topics: [topic0, txTopic], + }); + for (const log of regLogs) { + links.push({ + layer: 'activityRegistryV1', + label: `Activity registry V1 (${role})`, + mainnetTxHash: log.transactionHash, + mainnetExplorerUrl: etherscanTx(log.transactionHash), + contractAddress: cfg.addressActivityRegistry, + logIndex: log.index, + meta: { role }, + }); + } + } catch { + /* non-fatal */ + } + } + } + + if (cfg.addressActivityRegistryV2) { + try { + const v2Logs = await provider.getLogs({ + address: cfg.addressActivityRegistryV2, + fromBlock, + toBlock: 'latest', + topics: [PAYMENT_ATTESTED_TOPIC0, txTopic], + }); + const decoded = decodePaymentAttestedLogs({ status: '1', result: logRows(v2Logs) }); + for (const row of decoded) { + if (row.chain138TxHash.toLowerCase() !== want) continue; + links.push({ + layer: 'activityRegistryV2', + label: 'ISO-20022 PaymentAttested (V2)', + mainnetTxHash: row.mainnetTxHash, + mainnetExplorerUrl: row.mainnetExplorerUrl, + contractAddress: cfg.addressActivityRegistryV2, + logIndex: row.logIndex ? parseInt(row.logIndex, 10) : undefined, + meta: { instructionId: row.instructionId, uetr: row.uetr }, + }); + } + } catch { + /* non-fatal */ + } + } + + if (cfg.participantSurface) { + try { + const surfaceLogs = await provider.getLogs({ + address: cfg.participantSurface, + fromBlock, + toBlock: 'latest', + topics: [CHAIN138_ACTIVITY_NOTIFIED_TOPIC0, null, txTopic], + }); + const decoded = decodeChain138ActivityNotifiedLogs({ + status: '1', + result: logRows(surfaceLogs), + }); + for (const row of decoded) { + if (row.chain138TxHash.toLowerCase() !== want) continue; + links.push({ + layer: 'participantSurface', + label: 'Participant surface notification', + mainnetTxHash: row.mainnetTxHash, + mainnetExplorerUrl: row.mainnetExplorerUrl, + contractAddress: cfg.participantSurface, + logIndex: row.logIndex ? parseInt(row.logIndex, 10) : undefined, + meta: { + participant: row.participant, + direction: String(row.direction), + }, + }); + } + } catch { + /* non-fatal */ + } + } + + return uniqLinks(links); +} diff --git a/services/token-aggregation/public/chain138-attestation/overlay.css b/services/token-aggregation/public/chain138-attestation/overlay.css new file mode 100644 index 0000000..5768ebb --- /dev/null +++ b/services/token-aggregation/public/chain138-attestation/overlay.css @@ -0,0 +1,107 @@ +#chain138-mainnet-attestation-panel { + position: fixed; + top: 4.25rem; + left: 0; + right: 0; + z-index: 35; + margin: 0 auto; + max-width: 72rem; + padding: 0.5rem 1rem 0; + pointer-events: none; +} + +#chain138-mainnet-attestation-panel .c138-card { + pointer-events: auto; +} + +#chain138-mainnet-attestation-panel .c138-card { + border: 1px solid rgba(37, 99, 235, 0.25); + border-radius: 0.75rem; + background: linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(15, 23, 42, 0.02)); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); + padding: 1rem 1.25rem; +} + +#chain138-mainnet-attestation-panel .c138-title { + font-size: 0.95rem; + font-weight: 600; + color: #1e3a8a; + margin: 0 0 0.35rem; +} + +#chain138-mainnet-attestation-panel .c138-sub { + font-size: 0.82rem; + color: #475569; + margin: 0 0 0.75rem; +} + +#chain138-mainnet-attestation-panel .c138-links { + display: grid; + gap: 0.5rem; +} + +#chain138-mainnet-attestation-panel .c138-link-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + padding: 0.55rem 0.65rem; + border-radius: 0.5rem; + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(148, 163, 184, 0.35); +} + +#chain138-mainnet-attestation-panel .c138-layer { + font-size: 0.78rem; + font-weight: 600; + color: #0f172a; + min-width: 11rem; +} + +#chain138-mainnet-attestation-panel a.c138-etherscan { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.78rem; + color: #2563eb; + text-decoration: none; + word-break: break-all; +} + +#chain138-mainnet-attestation-panel a.c138-etherscan:hover { + text-decoration: underline; +} + +#chain138-mainnet-attestation-panel .c138-meta { + font-size: 0.72rem; + color: #64748b; +} + +#chain138-mainnet-attestation-panel .c138-pending { + font-size: 0.82rem; + color: #64748b; +} + +@media (prefers-color-scheme: dark) { + #chain138-mainnet-attestation-panel .c138-card { + border-color: rgba(96, 165, 250, 0.25); + background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(15, 23, 42, 0.55)); + box-shadow: none; + } + #chain138-mainnet-attestation-panel .c138-title { + color: #93c5fd; + } + #chain138-mainnet-attestation-panel .c138-sub, + #chain138-mainnet-attestation-panel .c138-pending, + #chain138-mainnet-attestation-panel .c138-meta { + color: #94a3b8; + } + #chain138-mainnet-attestation-panel .c138-link-row { + background: rgba(15, 23, 42, 0.65); + border-color: rgba(71, 85, 105, 0.55); + } + #chain138-mainnet-attestation-panel .c138-layer { + color: #e2e8f0; + } + #chain138-mainnet-attestation-panel a.c138-etherscan { + color: #60a5fa; + } +} diff --git a/services/token-aggregation/public/chain138-attestation/overlay.js b/services/token-aggregation/public/chain138-attestation/overlay.js new file mode 100644 index 0000000..320a62c --- /dev/null +++ b/services/token-aggregation/public/chain138-attestation/overlay.js @@ -0,0 +1,154 @@ +(function () { + 'use strict'; + window.__CHAIN138_ATTESTATION_OVERLAY__ = '1.0.0'; + + var API_BASE = '/api/v1/checkpoint/tx'; + var PANEL_ID = 'chain138-mainnet-attestation-panel'; + var lastHash = ''; + + function txHashFromPath() { + var m = window.location.pathname.match(/\/(?:transactions|tx)\/(0x[a-fA-F0-9]{64})\/?$/); + return m ? m[1] : null; + } + + function shortHash(hash) { + return hash.slice(0, 10) + '…' + hash.slice(-8); + } + + function ensurePanel() { + var existing = document.getElementById(PANEL_ID); + if (existing) return existing; + var panel = document.createElement('div'); + panel.id = PANEL_ID; + panel.setAttribute('data-chain138-attestation', '1'); + document.body.appendChild(panel); + return panel; + } + + function watchPanel() { + if (window.__CHAIN138_ATTESTATION_WATCH__) return; + window.__CHAIN138_ATTESTATION_WATCH__ = 1; + var observer = new MutationObserver(function () { + if (!document.getElementById(PANEL_ID) && lastHash) { + loadAttestation(lastHash); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + function renderLoading(panel) { + panel.innerHTML = + '

Checking Ethereum mainnet attestation…

'; + } + + function renderNone(panel) { + panel.innerHTML = ''; + } + + function renderAttestation(panel, data) { + if (!data || !data.included) { + panel.innerHTML = ''; + return; + } + + var links = Array.isArray(data.etherscanLinks) ? data.etherscanLinks : []; + var batchId = data.batchId && data.batchId !== '0' ? data.batchId : null; + var usd = data.leaf && data.leaf.valueUsd ? data.leaf.valueUsd : data.batchTotalUsd; + + var html = '
'; + html += '

Ethereum mainnet attestation

'; + html += + '

This Chain 138 transaction is included in checkpoint batch' + + (batchId ? ' #' + batchId : '') + + (usd ? ' · USD ref ' + usd : '') + + '.

'; + + if (!links.length) { + html += + '

Attested on mainnet; Etherscan transaction links are indexing. Refresh in a minute or open the checkpoint contracts on Etherscan.

'; + } else { + html += ''; + } + + html += '
'; + panel.innerHTML = html; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function escapeAttr(s) { + return escapeHtml(s).replace(/'/g, '''); + } + + function loadAttestation(txHash) { + if (!txHash) return; + var panel = ensurePanel(); + var rendered = panel.getAttribute('data-tx-hash'); + if (rendered === txHash && panel.querySelector('.c138-card')) return; + lastHash = txHash; + renderLoading(panel); + fetch(API_BASE + '/' + encodeURIComponent(txHash) + '/attestation', { + headers: { Accept: 'application/json' }, + }) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + var active = document.getElementById(PANEL_ID) || ensurePanel(); + renderAttestation(active, data); + if (data && data.included) active.setAttribute('data-tx-hash', txHash); + }) + .catch(function () { + renderNone(panel); + lastHash = ''; + }); + } + + function onRouteChange() { + var hash = txHashFromPath(); + if (!hash) { + lastHash = ''; + var panel = document.getElementById(PANEL_ID); + if (panel) panel.innerHTML = ''; + return; + } + loadAttestation(hash); + } + + function boot() { + watchPanel(); + onRouteChange(); + window.addEventListener('popstate', onRouteChange); + window.addEventListener('hashchange', onRouteChange); + setInterval(onRouteChange, 2000); + } + + boot(); + window.addEventListener('load', onRouteChange); + document.addEventListener('DOMContentLoaded', onRouteChange); +})(); diff --git a/services/token-aggregation/src/api/routes/checkpoint.ts b/services/token-aggregation/src/api/routes/checkpoint.ts index fa91e95..1b6ede7 100644 --- a/services/token-aggregation/src/api/routes/checkpoint.ts +++ b/services/token-aggregation/src/api/routes/checkpoint.ts @@ -6,9 +6,22 @@ import { logger } from '../../utils/logger'; const router: Router = Router(); function indexerBase(): string { - return (process.env.CHECKPOINT_INDEXER_URL || 'http://127.0.0.1:3099').replace(/\/$/, ''); + const port = process.env.CHECKPOINT_INDEXER_PORT || '3100'; + return ( + process.env.CHECKPOINT_INDEXER_URL || `http://127.0.0.1:${port}` + ).replace(/\/$/, ''); } +type MainnetAttestationLink = { + layer: string; + label: string; + mainnetTxHash: string; + mainnetExplorerUrl: string; + contractAddress?: string; + logIndex?: number; + meta?: Record; +}; + function batchDir(): string { const custom = process.env.CHECKPOINT_BATCH_DIR?.trim(); if (custom) return custom; @@ -49,6 +62,16 @@ function loadLocalAttestation(txHash: string): { batchId: string; batchTotalUsd?: string; leaf: CheckpointLeaf | null; + batchMeta: { + mainnetAttestation?: { + hubTx?: string; + mirrorTx?: string; + activityRegistryV1Tx?: string; + activityRegistryV2Tx?: string; + participantSurfaceTx?: string; + participantTopLevelTxs?: string[]; + }; + } | null; } | null { const dir = batchDir(); if (!existsSync(dir)) return null; @@ -64,11 +87,25 @@ function loadLocalAttestation(txHash: string): { batchId?: string; batchTotalUsd?: string; leaves?: CheckpointLeaf[]; + mainnetAttestation?: { + hubTx?: string; + mirrorTx?: string; + activityRegistryV1Tx?: string; + activityRegistryV2Tx?: string; + participantSurfaceTx?: string; + participantTopLevelTxs?: string[]; + }; }; const leaf = findLeaf(raw, want); if (!leaf) continue; const batchId = String(raw.batchId ?? file.replace(/^batch-|\.json$/gi, '')); - return { included: true, batchId, batchTotalUsd: raw.batchTotalUsd, leaf }; + return { + included: true, + batchId, + batchTotalUsd: raw.batchTotalUsd, + leaf, + batchMeta: raw, + }; } catch { continue; } @@ -76,6 +113,51 @@ function loadLocalAttestation(txHash: string): { return null; } +function linksFromBatchMeta(raw: { + mainnetAttestation?: { + hubTx?: string; + mirrorTx?: string; + activityRegistryV1Tx?: string; + activityRegistryV2Tx?: string; + participantSurfaceTx?: string; + participantTopLevelTxs?: string[]; + }; +} | null): MainnetAttestationLink[] { + const meta = raw?.mainnetAttestation; + if (!meta) return []; + const rows: Array<{ key: keyof NonNullable; layer: string; label: string }> = [ + { key: 'hubTx', layer: 'checkpointHub', label: 'Checkpoint batch (hub)' }, + { key: 'mirrorTx', layer: 'transactionMirror', label: 'Transaction mirror' }, + { key: 'activityRegistryV1Tx', layer: 'activityRegistryV1', label: 'Activity registry V1' }, + { key: 'activityRegistryV2Tx', layer: 'activityRegistryV2', label: 'ISO-20022 PaymentAttested (V2)' }, + { key: 'participantSurfaceTx', layer: 'participantSurface', label: 'Participant surface' }, + ]; + const links: MainnetAttestationLink[] = []; + for (const row of rows) { + const hash = meta[row.key]; + if (typeof hash === 'string' && /^0x[a-fA-F0-9]{64}$/.test(hash)) { + links.push({ + layer: row.layer, + label: row.label, + mainnetTxHash: hash, + mainnetExplorerUrl: `https://etherscan.io/tx/${hash}`, + }); + } + } + if (Array.isArray(meta.participantTopLevelTxs)) { + for (const hash of meta.participantTopLevelTxs) { + if (!/^0x[a-fA-F0-9]{64}$/.test(hash)) continue; + links.push({ + layer: 'participantWallet', + label: 'Participant wallet (0 ETH attestation tx)', + mainnetTxHash: hash, + mainnetExplorerUrl: `https://etherscan.io/tx/${hash}`, + }); + } + } + return links; +} + function leafResponse(leaf: CheckpointLeaf | null) { if (!leaf) return null; return { @@ -92,8 +174,54 @@ function leafResponse(leaf: CheckpointLeaf | null) { }; } +function mergeLinks(...groups: MainnetAttestationLink[][]): MainnetAttestationLink[] { + const out: MainnetAttestationLink[] = []; + const seen = new Set(); + for (const group of groups) { + for (const link of group) { + const key = `${link.layer}:${link.mainnetTxHash}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(link); + } + } + return out; +} + +async function fetchIndexerAttestation( + txHash: string, + timeoutMs: number, +): Promise<{ + txHash?: string; + included?: boolean; + batchId?: string; + payload?: { leaves?: CheckpointLeaf[]; batchTotalUsd?: string }; + etherscanLinks?: MainnetAttestationLink[]; + etherscan?: { base?: string; txUrlTemplate?: string }; +} | null> { + const url = `${indexerBase()}/v1/tx/${txHash}/attestation`; + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + const upstream = await fetch(url, { signal: ctrl.signal }); + clearTimeout(timer); + if (!upstream.ok) return null; + return (await upstream.json()) as { + txHash?: string; + included?: boolean; + batchId?: string; + payload?: { leaves?: CheckpointLeaf[]; batchTotalUsd?: string }; + etherscanLinks?: MainnetAttestationLink[]; + etherscan?: { base?: string; txUrlTemplate?: string }; + }; + } catch (e) { + logger.debug('checkpoint indexer proxy miss', { txHash, error: String(e) }); + return null; + } +} + /** - * GET /checkpoint/tx/:txHash/attestation — USD-enriched attestation (indexer proxy + local batch fallback). + * GET /checkpoint/tx/:txHash/attestation — USD-enriched attestation (local batch first, indexer enrich). */ router.get('/checkpoint/tx/:txHash/attestation', async (req: Request, res: Response) => { const txHash = String(req.params.txHash || '').trim(); @@ -101,34 +229,45 @@ router.get('/checkpoint/tx/:txHash/attestation', async (req: Request, res: Respo return res.status(400).json({ error: 'invalid tx hash' }); } - const url = `${indexerBase()}/v1/tx/${txHash}/attestation`; - try { - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 12_000); - const upstream = await fetch(url, { signal: ctrl.signal }); - clearTimeout(timer); - if (upstream.ok) { - const raw = (await upstream.json()) as { - txHash?: string; - included?: boolean; - batchId?: string; - payload?: { leaves?: CheckpointLeaf[]; batchTotalUsd?: string }; - }; - const leaf = findLeaf(raw.payload ?? null, txHash); - return res.json({ - txHash: raw.txHash ?? txHash, - included: Boolean(raw.included), - batchId: raw.batchId ?? '0', - batchTotalUsd: raw.payload?.batchTotalUsd, - leaf: leafResponse(leaf), - source: 'checkpoint-indexer', - }); - } - } catch (e) { - logger.debug('checkpoint indexer proxy miss', { txHash, error: String(e) }); - } + const etherscanMeta = { + base: 'https://etherscan.io', + txUrlTemplate: 'https://etherscan.io/tx/{txHash}', + }; const local = loadLocalAttestation(txHash); + const metaLinks = local ? linksFromBatchMeta(local.batchMeta) : []; + + // Fast path: synced batch files on the explorer CT already carry mainnet tx hashes. + if (local && metaLinks.length > 0) { + return res.json({ + txHash, + included: true, + batchId: local.batchId, + batchTotalUsd: local.batchTotalUsd, + leaf: leafResponse(local.leaf), + etherscanLinks: metaLinks, + etherscan: etherscanMeta, + source: 'checkpoint-batch-dir', + }); + } + + const indexerTimeoutMs = Number(process.env.CHECKPOINT_INDEXER_FETCH_TIMEOUT_MS || 2500); + const remote = await fetchIndexerAttestation(txHash, indexerTimeoutMs); + + if (remote?.included) { + const leaf = findLeaf(remote.payload ?? null, txHash) ?? local?.leaf ?? null; + return res.json({ + txHash: remote.txHash ?? txHash, + included: true, + batchId: remote.batchId ?? local?.batchId ?? '0', + batchTotalUsd: remote.payload?.batchTotalUsd ?? local?.batchTotalUsd, + leaf: leafResponse(leaf), + etherscanLinks: mergeLinks(metaLinks, remote.etherscanLinks ?? []), + etherscan: remote.etherscan ?? etherscanMeta, + source: local ? 'checkpoint-batch-dir+indexer' : 'checkpoint-indexer', + }); + } + if (local) { return res.json({ txHash, @@ -136,6 +275,8 @@ router.get('/checkpoint/tx/:txHash/attestation', async (req: Request, res: Respo batchId: local.batchId, batchTotalUsd: local.batchTotalUsd, leaf: leafResponse(local.leaf), + etherscanLinks: metaLinks, + etherscan: etherscanMeta, source: 'checkpoint-batch-dir', }); } @@ -145,6 +286,8 @@ router.get('/checkpoint/tx/:txHash/attestation', async (req: Request, res: Respo included: false, batchId: '0', leaf: null, + etherscanLinks: [], + etherscan: etherscanMeta, source: 'none', }); }); diff --git a/services/token-aggregation/src/api/server.ts b/services/token-aggregation/src/api/server.ts index fecb955..ebe5938 100644 --- a/services/token-aggregation/src/api/server.ts +++ b/services/token-aggregation/src/api/server.ts @@ -123,6 +123,10 @@ export class ApiServer { this.app.use('/static', express.static(publicPath, staticOpts)); this.app.use('/omnl/static', express.static(publicPath, staticOpts)); this.app.use('/reserve/static', express.static(publicPath, staticOpts)); + this.app.use( + '/chain138-attestation', + express.static(path.join(publicPath, 'chain138-attestation'), staticOpts) + ); } }