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
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:
+2
-1
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ?? ''),
|
||||
|
||||
@@ -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, '&')
|
||||
.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);
|
||||
})();
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user