feat(checkpoint): expose mainnet Etherscan links on Chain 138 tx attestation.
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 <[email protected]>
This commit is contained in:
defiQUG
2026-06-08 08:13:59 -07:00
co-authored by Cursor
parent 183f3cffc9
commit d54882cd0f
18 changed files with 1310 additions and 105 deletions
+2 -1
View File
@@ -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)
+4 -2
View File
@@ -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)`);
}
+63 -18
View File
@@ -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;
}
+2 -1
View File
@@ -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 {
+4 -2
View File
@@ -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);
@@ -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<string>();
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<number> {
const seen = new Set<string>();
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;
+54 -10
View File
@@ -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') {
+14 -2
View File
@@ -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 ?? ''),
+29 -3
View File
@@ -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) });
+235
View File
@@ -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);
}
@@ -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<typeof decodePaymentAttestedLogs> } | 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<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: 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.',
});
}
@@ -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 ?? ''),
+38 -5
View File
@@ -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<string, unknown>;
} | 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<string, unknown>;
};
}
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<ReturnType<typeof resolveTxMainnetLinks>> = [];
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) });
}
@@ -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<string, string>;
};
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<string>();
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<number> {
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<MainnetAttestationLink[]> {
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);
}
@@ -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;
}
}
@@ -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 =
'<div class="c138-card"><p class="c138-pending">Checking Ethereum mainnet attestation…</p></div>';
}
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 = '<div class="c138-card">';
html += '<h2 class="c138-title">Ethereum mainnet attestation</h2>';
html +=
'<p class="c138-sub">This Chain 138 transaction is included in checkpoint batch' +
(batchId ? ' #' + batchId : '') +
(usd ? ' · USD ref ' + usd : '') +
'.</p>';
if (!links.length) {
html +=
'<p class="c138-pending">Attested on mainnet; Etherscan transaction links are indexing. Refresh in a minute or open the checkpoint contracts on Etherscan.</p>';
} else {
html += '<div class="c138-links">';
for (var i = 0; i < links.length; i++) {
var row = links[i];
html += '<div class="c138-link-row">';
html += '<span class="c138-layer">' + escapeHtml(row.label || row.layer || 'Mainnet') + '</span>';
html +=
'<a class="c138-etherscan" href="' +
escapeAttr(row.mainnetExplorerUrl) +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(shortHash(row.mainnetTxHash)) +
' ↗</a>';
if (row.meta && row.meta.role) {
html += '<span class="c138-meta">role: ' + escapeHtml(row.meta.role) + '</span>';
}
if (row.meta && row.meta.uetr) {
html += '<span class="c138-meta">UETR: ' + escapeHtml(row.meta.uetr) + '</span>';
}
html += '</div>';
}
html += '</div>';
}
html += '</div>';
panel.innerHTML = html;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;');
}
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);
})();
@@ -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<string, string>;
};
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<typeof meta>; 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<string>();
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',
});
});
@@ -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)
);
}
}