diff --git a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumHcdvsService.java b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumHcdvsService.java index 50ebbd11..228d78f5 100644 --- a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumHcdvsService.java +++ b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumHcdvsService.java @@ -1,16 +1,13 @@ package com.alipay.antchain.bridge.plugins.ethereum2; import java.math.BigInteger; -import java.util.Arrays; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.ObjectUtil; import com.alipay.antchain.bridge.commons.core.base.ConsensusState; import com.alipay.antchain.bridge.commons.core.base.CrossChainMessage; import com.alipay.antchain.bridge.commons.core.bta.IBlockchainTrustAnchor; -import com.alipay.antchain.bridge.plugins.ethereum2.abi.AuthMsg; import com.alipay.antchain.bridge.plugins.ethereum2.core.*; -import com.alipay.antchain.bridge.plugins.ethereum2.core.eth.EthLogTopic; import com.alipay.antchain.bridge.plugins.ethereum2.core.eth.EthReceiptProof; import com.alipay.antchain.bridge.plugins.lib.HeteroChainDataVerifierService; import com.alipay.antchain.bridge.plugins.spi.ptc.AbstractHCDVSService; @@ -18,15 +15,12 @@ import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes32; import org.hyperledger.besu.datatypes.Address; -import org.web3j.tx.Contract; import org.web3j.utils.Numeric; import tech.pegasys.teku.infrastructure.unsigned.UInt64; @HeteroChainDataVerifierService(pluginId = "plugin-ethereum2", products = "ethereum2") public class EthereumHcdvsService extends AbstractHCDVSService { - private static final EthLogTopic SEND_AUTH_MESSAGE_LOG_TOPIC = EthLogTopic.fromHexString("0x79b7516b1b7a6a39fb4b7b22e8667cd3744e5c27425292f8a9f49d1042c0c651"); - @Override public VerifyResult verifyAnchorConsensusState(IBlockchainTrustAnchor bta, ConsensusState anchorState) { getHCDVSLogger().info("verify anchor consensus state ⚓️ (slot: {}, hash: {}) for domain {} now!", @@ -181,38 +175,18 @@ public VerifyResult verifyCrossChainMessage(CrossChainMessage message, Consensus return VerifyResult.fail("receipt root not equal"); } - var ethAuthMessageLog = EthAuthMessageLog.decodeFromJson(new String(message.getProvableData().getLedgerData())); - var receiptInProof = ethReceiptProof.getEthTransactionReceipt(); - if (receiptInProof.getLogs().size() <= ethAuthMessageLog.getLogIndex()) { - getHCDVSLogger().error("❌ log index {} out of range, receipt has only {} logs", ethAuthMessageLog.getLogIndex(), receiptInProof.getLogs().size()); - return VerifyResult.fail("log index out of range"); - } - - var msgLogInProof = ethReceiptProof.getEthTransactionReceipt().getLogs().get(ethAuthMessageLog.getLogIndex()); - var msgLogInLedgerData = ethAuthMessageLog.getSendAuthMessageLog(); - - if (!SEND_AUTH_MESSAGE_LOG_TOPIC.equals(msgLogInProof.getTopics().getFirst())) { - getHCDVSLogger().error("❌ log topic in proof {} not match", msgLogInProof.getTopics().getFirst().toHexString()); - return VerifyResult.fail("log topic not match"); - } - if (!Arrays.equals(SEND_AUTH_MESSAGE_LOG_TOPIC.toArray(), Numeric.hexStringToByteArray(msgLogInLedgerData.getTopics().getFirst()))) { - getHCDVSLogger().error("❌ log topic in ledger data {} not match", msgLogInLedgerData.getTopics().getFirst()); - return VerifyResult.fail("log topic not match"); - } - if (!msgLogInProof.getLogger().equals(ethConsensusStateData.getAmContract())) { - getHCDVSLogger().error("❌ logger address in proof {} is not am contract {}", - msgLogInProof.getLogger().toHexString(), ethConsensusStateData.getAmContract().toHexString()); - return VerifyResult.fail("logger not am contract"); - } - if (!Arrays.equals(ethConsensusStateData.getAmContract().toArray(), Numeric.hexStringToByteArray(msgLogInLedgerData.getAddress()))) { - getHCDVSLogger().error("❌ logger address {} in ledger data is not am contract {}", - msgLogInLedgerData.getAddress(), ethConsensusStateData.getAmContract().toHexString()); - return VerifyResult.fail("logger not am contract"); - } - if (!Arrays.equals(msgLogInProof.getData().toArray(), Numeric.hexStringToByteArray(msgLogInLedgerData.getData()))) { - getHCDVSLogger().error("❌ log data in proof {} is not equal to ledger data {}", - msgLogInProof.getData().toHexString(), msgLogInLedgerData.getData()); - return VerifyResult.fail("log data not match"); + try { + var ledgerLog = EthAuthMessageLog.decodeFromJson(new String(message.getProvableData().getLedgerData())); + if (ledgerLog == null || ledgerLog.getSendAuthMessageLog() == null + || !BigInteger.valueOf(ethReceiptProof.getReceiptIndex()).equals( + ledgerLog.getSendAuthMessageLog().getTransactionIndex())) { + return VerifyResult.fail("receipt transaction index does not match ledger event"); + } + ledgerLog.verifyReceiptLog(ethReceiptProof.getEthTransactionReceipt().getLogs(), + ethConsensusStateData.getAmContract(), message.getMessage()); + } catch (RuntimeException e) { + // Malformed/ambiguous ledger data must fail verification, never fall back to RPC or success. + return VerifyResult.fail("invalid receipt event: {}", e.getMessage()); } getHCDVSLogger().info("🌈 crosschain message (slot: {}, txhash: {}) pass the verification", @@ -223,10 +197,6 @@ public VerifyResult verifyCrossChainMessage(CrossChainMessage message, Consensus @Override public byte[] parseMessageFromLedgerData(byte[] ledgerData) { - var eventValues = Contract.staticExtractEventParameters( - AuthMsg.SENDAUTHMESSAGE_EVENT, - EthAuthMessageLog.decodeFromJson(new String(ledgerData)).getSendAuthMessageLog() - ); - return (byte[]) eventValues.getNonIndexedValues().getFirst().getValue(); + return EthAuthMessageLog.decodeFromJson(new String(ledgerData)).decodeMessage(); } } diff --git a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/core/AcbEthClient.java b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/core/AcbEthClient.java index 532adb5a..731e6354 100644 --- a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/core/AcbEthClient.java +++ b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/core/AcbEthClient.java @@ -1031,7 +1031,7 @@ private List readMessagesFromEntireBlock(BeaconBlock beaconBl List finalAllReceiptsInBlock = allReceiptsInBlock; messageList.addAll(AuthMsg.getSendAuthMessageEvents(receipt).stream() - .filter(x -> StrUtil.equals(x.log.getAddress(), amContractAddressHex)) + .filter(x -> StrUtil.equalsIgnoreCase(x.log.getAddress(), amContractAddressHex)) .map( response -> CrossChainMessage.createCrossChainMessage( CrossChainMessage.CrossChainMessageType.AUTH_MSG, @@ -1039,11 +1039,7 @@ private List readMessagesFromEntireBlock(BeaconBlock beaconBl block.getTimestamp().longValue() * 1000, beaconBlock.getRoot().toArray(), response.pkg, - EthAuthMessageLog.builder() - .logIndex(response.log.getLogIndex().intValue()) - .sendAuthMessageLog(response.log) - .build() - .encodeToJson().getBytes(), + EthAuthMessageLog.fromReceipt(receipt, response.log).encodeToJson().getBytes(), getReceiptProof(finalAllReceiptsInBlock, response.log.getTransactionIndex().intValue()).encodeToJson().getBytes(), Numeric.hexStringToByteArray(receipt.getTransactionHash()) ) @@ -1082,8 +1078,8 @@ private List readMessagesByFilter(BeaconBlock beaconBlock, Bi getBbcLogger().warn("log from node has wrong contract address: {}, expected: {}", logObject.getAddress(), amContractAddressHex); continue; } - if (logObject.getTopics().size() != 1 || !StrUtil.equalsIgnoreCase(logObject.getTopics().getFirst(), SEND_AUTH_MESSAGE_LOG_TOPIC)) { - getBbcLogger().warn("log from node has wrong topic: {}, expected: {}", logObject.getTopics().getFirst(), SEND_AUTH_MESSAGE_LOG_TOPIC); + if (logObject.getTopics() == null || logObject.getTopics().size() != 1 || !StrUtil.equalsIgnoreCase(logObject.getTopics().getFirst(), SEND_AUTH_MESSAGE_LOG_TOPIC)) { + getBbcLogger().warn("log from node has wrong topics: {}, expected: {}", logObject.getTopics(), SEND_AUTH_MESSAGE_LOG_TOPIC); continue; } @@ -1097,24 +1093,18 @@ private List readMessagesByFilter(BeaconBlock beaconBlock, Bi var blockTimestamp = block.getTimestamp().longValue() * 1000; var receiptProof = getReceiptProof(allReceiptsInBlock, logObject.getTransactionIndex().intValue()); - messageList.addAll( - AuthMsg.getSendAuthMessageEvents(transactionReceipt).stream().map( - response -> CrossChainMessage.createCrossChainMessage( - CrossChainMessage.CrossChainMessageType.AUTH_MSG, - beaconBlock.getSlot().bigIntegerValue(), - blockTimestamp, - beaconBlock.getRoot().toArray(), - response.pkg, - EthAuthMessageLog.builder() - .logIndex(logObject.getLogIndex().intValue()) - .sendAuthMessageLog(logObject) - .build() - .encodeToJson().getBytes(), - receiptProof.encodeToJson().getBytes(), - Numeric.hexStringToByteArray(logObject.getTransactionHash()) - ) - ).toList() - ); + // One filter result denotes ONE event, not every AM event in the same transaction. + var ledgerLog = EthAuthMessageLog.fromReceipt(transactionReceipt, logObject); + messageList.add(CrossChainMessage.createCrossChainMessage( + CrossChainMessage.CrossChainMessageType.AUTH_MSG, + beaconBlock.getSlot().bigIntegerValue(), + blockTimestamp, + beaconBlock.getRoot().toArray(), + ledgerLog.decodeMessage(), + ledgerLog.encodeToJson().getBytes(), + receiptProof.encodeToJson().getBytes(), + Numeric.hexStringToByteArray(transactionReceipt.getTransactionHash()) + )); } if (!messageList.isEmpty()) { diff --git a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/core/EthAuthMessageLog.java b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/core/EthAuthMessageLog.java index 24274368..fccc594e 100644 --- a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/core/EthAuthMessageLog.java +++ b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum2/core/EthAuthMessageLog.java @@ -1,8 +1,18 @@ package com.alipay.antchain.bridge.plugins.ethereum2.core; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + import com.alibaba.fastjson.JSON; +import com.alipay.antchain.bridge.plugins.ethereum2.abi.AuthMsg; +import com.alipay.antchain.bridge.plugins.ethereum2.core.eth.EthLog; import lombok.*; +import org.hyperledger.besu.datatypes.Address; import org.web3j.protocol.core.methods.response.Log; +import org.web3j.protocol.core.methods.response.TransactionReceipt; +import org.web3j.tx.Contract; +import org.web3j.utils.Numeric; @Builder @NoArgsConstructor @@ -11,14 +21,133 @@ @Setter public class EthAuthMessageLog { + private static final String TOPIC = "0x79b7516b1b7a6a39fb4b7b22e8667cd3744e5c27425292f8a9f49d1042c0c651"; + public static EthAuthMessageLog decodeFromJson(String json) { return JSON.parseObject(json, EthAuthMessageLog.class); } + // Receipt-local for new messages. Old collectors populated this with the block-global index. private Integer logIndex; + // Explicit marker: validators must not fall back to content lookup when this is present. + private Integer receiptLogIndex; + + // Preserve the original RPC metadata, including its block-global logIndex. private Log sendAuthMessageLog; + public static EthAuthMessageLog fromReceipt(TransactionReceipt receipt, Log selected) { + require(receipt != null && receipt.getLogs() != null && selected != null, "missing receipt/log"); + require(!selected.isRemoved(), "removed log"); + require(selected.getLogIndex() != null && selected.getLogIndex().signum() >= 0 + && selected.getTransactionIndex() != null && selected.getTransactionIndex().signum() >= 0, + "missing or negative RPC index"); + require(sameHex(receipt.getTransactionHash(), selected.getTransactionHash()) + && Objects.equals(receipt.getTransactionIndex(), selected.getTransactionIndex()), + "log transaction does not match receipt"); + int found = -1; + for (int i = 0; i < receipt.getLogs().size(); i++) { + Log candidate = receipt.getLogs().get(i); + if (Objects.equals(candidate.getLogIndex(), selected.getLogIndex()) + && sameHex(candidate.getTransactionHash(), selected.getTransactionHash()) + && sameHex(candidate.getBlockHash(), selected.getBlockHash()) + && sameRpcContent(candidate, selected)) { + require(found == -1, "ambiguous RPC log"); + found = i; + } + } + require(found >= 0, "selected log missing from receipt"); + return EthAuthMessageLog.builder().logIndex(found).receiptLogIndex(found) + .sendAuthMessageLog(receipt.getLogs().get(found)).build(); + } + + public byte[] decodeMessage() { + require(sendAuthMessageLog != null && sendAuthMessageLog.getTopics() != null + && sendAuthMessageLog.getTopics().size() == 1 + && TOPIC.equalsIgnoreCase(sendAuthMessageLog.getTopics().getFirst()), "invalid AM event topic"); + var event = Contract.staticExtractEventParameters(AuthMsg.SENDAUTHMESSAGE_EVENT, sendAuthMessageLog); + require(event != null && event.getNonIndexedValues().size() == 1, "invalid AM event data"); + return (byte[]) event.getNonIndexedValues().getFirst().getValue(); + } + + /** + * Call only after validating the receipt proof against the trusted consensus receipts root. + * Legacy compatibility searches authenticated receipt content, never a node/RPC response. + */ + public EthLog verifyReceiptLog(List logs, Address amContract, byte[] message) { + require(logs != null && sendAuthMessageLog != null && logIndex != null && logIndex >= 0, + "missing or negative log index"); + require(!sendAuthMessageLog.isRemoved(), "removed log"); + require(Arrays.equals(amContract.toArray(), hex(sendAuthMessageLog.getAddress())), "logger not AM contract"); + require(Arrays.equals(message, decodeMessage()), "message does not match ledger event"); + + if (receiptLogIndex != null) { + require(receiptLogIndex.equals(logIndex) && receiptLogIndex >= 0 && receiptLogIndex < logs.size(), + "receipt log index out of range or inconsistent"); + EthLog selected = logs.get(receiptLogIndex); + require(matchesProof(selected), "indexed receipt log does not match ledger event"); + return selected; + } + + // Old producers used inconsistent index semantics. Only a UNIQUE complete event match is safe. + EthLog match = null; + int matchedIndex = -1; + for (int i = 0; i < logs.size(); i++) { + EthLog candidate = logs.get(i); + if (matchesProof(candidate)) { + require(match == null, "ambiguous legacy receipt log"); + match = candidate; + matchedIndex = i; + } + } + require(match != null, "ledger event missing from proven receipt"); + require(logIndex == matchedIndex || java.math.BigInteger.valueOf(logIndex).equals(sendAuthMessageLog.getLogIndex()), + "inconsistent legacy log index"); + return match; + } + + private boolean matchesProof(EthLog proofLog) { + if (!Arrays.equals(proofLog.getLogger().toArray(), hex(sendAuthMessageLog.getAddress())) + || !Arrays.equals(proofLog.getData().toArray(), hex(sendAuthMessageLog.getData())) + || proofLog.getTopics().size() != sendAuthMessageLog.getTopics().size()) { + return false; + } + for (int i = 0; i < proofLog.getTopics().size(); i++) { + if (!Arrays.equals(proofLog.getTopics().get(i).toArray(), hex(sendAuthMessageLog.getTopics().get(i)))) { + return false; + } + } + return true; + } + + private static boolean sameRpcContent(Log a, Log b) { + if (!sameHex(a.getAddress(), b.getAddress()) || !sameHex(a.getData(), b.getData()) + || a.getTopics() == null || b.getTopics() == null || a.getTopics().size() != b.getTopics().size()) { + return false; + } + for (int i = 0; i < a.getTopics().size(); i++) { + if (!sameHex(a.getTopics().get(i), b.getTopics().get(i))) { + return false; + } + } + return true; + } + + private static boolean sameHex(String a, String b) { + return a != null && b != null && a.equalsIgnoreCase(b); + } + + private static byte[] hex(String value) { + require(value != null && value.matches("(?i)0x(?:[0-9a-f]{2})*"), "invalid hex in ledger event"); + return Numeric.hexStringToByteArray(value); + } + + private static void require(boolean valid, String error) { + if (!valid) { + throw new IllegalArgumentException(error); + } + } + public String encodeToJson() { return JSON.toJSONString(this); } diff --git a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumCollectorLogIndexTest.java b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumCollectorLogIndexTest.java new file mode 100644 index 00000000..3db45f6a --- /dev/null +++ b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumCollectorLogIndexTest.java @@ -0,0 +1,117 @@ +package com.alipay.antchain.bridge.plugins.ethereum2; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.util.List; + +import com.alipay.antchain.bridge.commons.core.base.CrossChainMessage; +import com.alipay.antchain.bridge.plugins.ethereum2.core.AcbEthClient; +import com.alipay.antchain.bridge.plugins.ethereum2.core.EthAuthMessageLog; +import com.alipay.antchain.bridge.plugins.ethereum2.core.eth.EthReceiptProof; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import org.web3j.protocol.Web3j; +import org.web3j.protocol.core.methods.response.*; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.datastructures.blocks.BeaconBlock; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +public class EthereumCollectorLogIndexTest { + private static void field(Object object, String name, Object value) throws Exception { + Field field = object.getClass().getSuperclass().getDeclaredField(name); + field.setAccessible(true); + field.set(object, value); + } + + private static TransactionReceipt receipt(int index, Log... logs) { + TransactionReceipt receipt = new TransactionReceipt(); + receipt.setTransactionHash("0x" + (index == 0 ? "aa" : "bb").repeat(32)); + receipt.setTransactionIndex("0x" + index); + receipt.setType("0x2"); + receipt.setStatus("0x1"); + receipt.setCumulativeGasUsed("0x100"); + receipt.setLogsBloom("0x" + "00".repeat(256)); + receipt.setLogs(List.of(logs)); + for (Log log : logs) { + log.setTransactionHash(receipt.getTransactionHash()); + log.setTransactionIndex("0x" + index); + } + return receipt; + } + + private static EthLog.LogObject log(int index, String body, boolean am) { + EthLog.LogObject log = new EthLog.LogObject(); + log.setLogIndex("0x" + index); + log.setBlockHash("0x" + "cd".repeat(32)); + log.setAddress("0x" + (am ? "11" : "22").repeat(20)); + log.setTopics(List.of("0x79b7516b1b7a6a39fb4b7b22e8667cd3744e5c27425292f8a9f49d1042c0c651")); + log.setData("0x" + org.web3j.abi.FunctionEncoder.encodeConstructor(List.of( + new org.web3j.abi.datatypes.DynamicBytes(body.getBytes()))).replaceFirst("^0x", "")); + return log; + } + + @Test public void blockScanAndFilterProduceTheSameTwoDistinctEvents() throws Exception { + Web3j web3j = mock(Web3j.class, RETURNS_DEEP_STUBS); + AcbEthClient client = mock(AcbEthClient.class, CALLS_REAL_METHODS); + field(client, "web3j", web3j); + field(client, "bbcLogger", LoggerFactory.getLogger(getClass())); + + var unrelated = log(0, "unrelated", false); + var first = log(1, "first", true); + var second = log(2, "second", true); + var receipts = List.of(receipt(0, unrelated), receipt(1, first, second)); + + var rpcReceipts = new EthGetBlockReceipts(); + rpcReceipts.setResult(receipts); + when(web3j.ethGetBlockReceipts(any()).send()).thenReturn(rpcReceipts); + var rpcLogs = new EthLog(); + rpcLogs.setResult(List.of(first, second)); + when(web3j.ethGetLogs(any()).send()).thenReturn(rpcLogs); + var block = new EthBlock.Block(); + block.setTimestamp("0x123"); + var tx0 = new EthBlock.TransactionObject(); + tx0.setHash(receipts.get(0).getTransactionHash()); + var tx1 = new EthBlock.TransactionObject(); + tx1.setHash(receipts.get(1).getTransactionHash()); + block.setTransactions(List.of(tx0, tx1)); + var rpcBlock = new EthBlock(); + rpcBlock.setResult(block); + when(web3j.ethGetBlockByNumber(any(), anyBoolean()).send()).thenReturn(rpcBlock); + for (var receipt : receipts) { + var response = new EthGetTransactionReceipt(); + response.setResult(receipt); + when(web3j.ethGetTransactionReceipt(receipt.getTransactionHash()).send()).thenReturn(response); + } + BeaconBlock beacon = mock(BeaconBlock.class); + when(beacon.getSlot()).thenReturn(UInt64.valueOf(123)); + when(beacon.getRoot()).thenReturn(Bytes32.fromHexString("0x" + "ef".repeat(32))); + + List previous = null; + for (String methodName : List.of("readMessagesByFilter", "readMessagesFromEntireBlock")) { + Method method = AcbEthClient.class.getDeclaredMethod(methodName, BeaconBlock.class, BigInteger.class, String.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + var messages = (List) method.invoke(client, beacon, BigInteger.ONE, "0x" + "11".repeat(20)); + assertEquals(2, messages.size()); // The former filter path emitted 4 and mismatched payloads. + for (int i = 0; i < messages.size(); i++) { + var message = messages.get(i); + var ledger = EthAuthMessageLog.decodeFromJson(new String(message.getProvableData().getLedgerData())); + assertEquals(Integer.valueOf(i), ledger.getReceiptLogIndex()); + assertEquals(BigInteger.valueOf(i + 1), ledger.getSendAuthMessageLog().getLogIndex()); + assertArrayEquals((i == 0 ? "first" : "second").getBytes(), message.getMessage()); + var proof = EthReceiptProof.decodeFromJson(new String(message.getProvableData().getProof())); + assertEquals(1, proof.getReceiptIndex()); + assertNotNull(proof.validateAndGetRoot()); + ledger.verifyReceiptLog(proof.getEthTransactionReceipt().getLogs(), + org.hyperledger.besu.datatypes.Address.fromHexString("0x" + "11".repeat(20)), message.getMessage()); + if (previous != null) assertArrayEquals(previous.get(i).encode(), message.encode()); + } + previous = messages; + } + } +} diff --git a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumReceiptLogIndexTest.java b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumReceiptLogIndexTest.java new file mode 100644 index 00000000..b9e407fd --- /dev/null +++ b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumReceiptLogIndexTest.java @@ -0,0 +1,158 @@ +package com.alipay.antchain.bridge.plugins.ethereum2; + +import java.math.BigInteger; +import java.util.List; +import java.nio.charset.StandardCharsets; + +import com.alipay.antchain.bridge.plugins.ethereum2.core.EthAuthMessageLog; +import com.alipay.antchain.bridge.plugins.ethereum2.core.eth.EthLog; +import com.alipay.antchain.bridge.plugins.ethereum2.core.eth.EthLogTopic; +import org.apache.tuweni.bytes.Bytes; +import org.hyperledger.besu.datatypes.Address; +import org.junit.Test; +import org.web3j.abi.FunctionEncoder; +import org.web3j.abi.datatypes.DynamicBytes; +import org.web3j.protocol.core.methods.response.Log; +import org.web3j.protocol.core.methods.response.TransactionReceipt; + +import static org.junit.Assert.*; + +public class EthereumReceiptLogIndexTest { + private static final String AM = "0x1111111111111111111111111111111111111111"; + private static final String OTHER = "0x2222222222222222222222222222222222222222"; + private static final String TOPIC = "0x79b7516b1b7a6a39fb4b7b22e8667cd3744e5c27425292f8a9f49d1042c0c651"; + + private static Log log(int globalIndex, String body) { + Log log = new Log(); + log.setLogIndex("0x" + Integer.toHexString(globalIndex)); + log.setTransactionIndex("0x1"); + log.setTransactionHash("0x" + "ab".repeat(32)); + log.setBlockHash("0x" + "cd".repeat(32)); + log.setAddress(AM); + log.setTopics(List.of(TOPIC)); + log.setData(FunctionEncoder.encodeConstructor(List.of(new DynamicBytes(body.getBytes(StandardCharsets.UTF_8))))); + if (!log.getData().startsWith("0x")) log.setData("0x" + log.getData()); + return log; + } + + private static TransactionReceipt receipt(Log... logs) { + TransactionReceipt receipt = new TransactionReceipt(); + receipt.setTransactionHash(logs[0].getTransactionHash()); + receipt.setTransactionIndex("0x1"); + receipt.setLogs(List.of(logs)); + return receipt; + } + + private static EthLog proofLog(Log log) { + return new EthLog(Address.fromHexString(log.getAddress()), Bytes.fromHexString(log.getData()), + log.getTopics().stream().map(EthLogTopic::fromHexString).toList()); + } + + private static void verify(EthAuthMessageLog ledger, List logs, String body) { + ledger.verifyReceiptLog(logs, Address.fromHexString(AM), body.getBytes(StandardCharsets.UTF_8)); + } + + @Test public void collectorUsesReceiptLocalIndexAndRetainsRpcGlobalIndex() { + Log unrelated = log(62, "unrelated"); + unrelated.setAddress(OTHER); + Log selected = log(63, "selected"); + EthAuthMessageLog ledger = EthAuthMessageLog.fromReceipt(receipt(unrelated, selected), selected); + assertEquals(Integer.valueOf(1), ledger.getLogIndex()); + assertEquals(Integer.valueOf(1), ledger.getReceiptLogIndex()); + assertEquals(BigInteger.valueOf(63), ledger.getSendAuthMessageLog().getLogIndex()); + assertArrayEquals("selected".getBytes(StandardCharsets.UTF_8), ledger.decodeMessage()); + verify(EthAuthMessageLog.decodeFromJson(ledger.encodeToJson()), List.of(proofLog(unrelated), proofLog(selected)), "selected"); + } + + @Test public void eachFilterEventResolvesOnlyItsOwnPayload() { + Log a = log(62, "one"); + Log b = log(63, "two"); + TransactionReceipt receipt = receipt(a, b); + var results = List.of(a, b).stream().map(log -> EthAuthMessageLog.fromReceipt(receipt, log)).toList(); + assertEquals(2, results.size()); + assertEquals(Integer.valueOf(0), results.get(0).getReceiptLogIndex()); + assertEquals(Integer.valueOf(1), results.get(1).getReceiptLogIndex()); + assertArrayEquals("one".getBytes(StandardCharsets.UTF_8), results.get(0).decodeMessage()); + assertArrayEquals("two".getBytes(StandardCharsets.UTF_8), results.get(1).decodeMessage()); + } + + @Test public void legacyGlobalIndexCanExceedReceiptSize() { + Log a = log(62, "one"); + var ledger = EthAuthMessageLog.builder().logIndex(62).sendAuthMessageLog(a).build(); + verify(ledger, List.of(proofLog(a), proofLog(log(63, "other"))), "one"); + assertEquals(Integer.valueOf(62), ledger.getLogIndex()); // Verification does not rewrite old ledger. + assertNull(ledger.getReceiptLogIndex()); + } + + @Test public void legacyInRangeIndexCannotSelectAnotherEvent() { + Log a = log(1, "one"); + var ledger = EthAuthMessageLog.builder().logIndex(1).sendAuthMessageLog(a).build(); + verify(ledger, List.of(proofLog(a), proofLog(log(2, "other"))), "one"); + ledger.setLogIndex(99); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(a)), "one")); + } + + @Test public void explicitInvalidIndexNeverFallsBack() { + Log a = log(62, "one"); + var ledger = EthAuthMessageLog.fromReceipt(receipt(a, log(63, "other")), a); + for (int index : new int[]{-1, 1, 62, Integer.MAX_VALUE}) { + ledger.setLogIndex(index); + ledger.setReceiptLogIndex(index); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(a), proofLog(log(63, "other"))), "one")); + } + ledger.setLogIndex(0); + ledger.setReceiptLogIndex(1); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(a)), "one")); + } + + @Test public void ambiguousLegacyEventsFailButExplicitPositionsRemainDistinct() { + Log a = log(62, "same"); + Log b = log(63, "same"); + var ledger = EthAuthMessageLog.builder().logIndex(62).sendAuthMessageLog(a).build(); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(a), proofLog(b)), "same")); + verify(EthAuthMessageLog.fromReceipt(receipt(a, b), a), List.of(proofLog(a), proofLog(b)), "same"); + verify(EthAuthMessageLog.fromReceipt(receipt(a, b), b), List.of(proofLog(a), proofLog(b)), "same"); + } + + @Test public void malformedOrTamperedEventCannotPass() { + Log a = log(62, "one"); + var ledger = EthAuthMessageLog.fromReceipt(receipt(a), a); + var proof = List.of(proofLog(a)); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "tampered")); + a.setAddress(OTHER); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + a.setAddress(AM); + a.setTopics(List.of(TOPIC, TOPIC)); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + a.setTopics(List.of()); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + a.setTopics(List.of(TOPIC)); + a.setData(log(62, "tampered").getData()); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "tampered")); + a.setData("0xzz"); + assertThrows(RuntimeException.class, () -> verify(ledger, proof, "one")); + } + + @Test public void nullNegativeRemovedAndUnprovenEventsFail() { + Log a = log(62, "one"); + var ledger = EthAuthMessageLog.builder().sendAuthMessageLog(a).build(); + var proof = List.of(proofLog(a)); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + ledger.setLogIndex(-1); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + ledger.setLogIndex(62); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(log(63, "other"))), "one")); + a.setRemoved(true); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + } + + @Test public void collectorRejectsMismatchAndAmbiguousRpcMetadata() { + Log a = log(62, "one"); + TransactionReceipt receipt = receipt(a); + assertThrows(IllegalArgumentException.class, () -> EthAuthMessageLog.fromReceipt(receipt, log(63, "one"))); + assertThrows(IllegalArgumentException.class, () -> EthAuthMessageLog.fromReceipt(receipt, log(62, "different"))); + assertThrows(IllegalArgumentException.class, () -> EthAuthMessageLog.fromReceipt(receipt(a, a), a)); + receipt.setTransactionHash("0x" + "ef".repeat(32)); + assertThrows(IllegalArgumentException.class, () -> EthAuthMessageLog.fromReceipt(receipt, a)); + } +} diff --git a/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumReceiptProofCompatibilityTest.java b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumReceiptProofCompatibilityTest.java new file mode 100644 index 00000000..63f17a27 --- /dev/null +++ b/acb-sdk/pluginset/ethereum2/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum2/EthereumReceiptProofCompatibilityTest.java @@ -0,0 +1,91 @@ +package com.alipay.antchain.bridge.plugins.ethereum2; + +import java.lang.reflect.Field; +import com.alibaba.fastjson.JSON; +import com.alipay.antchain.bridge.commons.core.base.ConsensusState; +import com.alipay.antchain.bridge.commons.core.base.CrossChainMessage; +import com.alipay.antchain.bridge.plugins.ethereum2.core.EthAuthMessageLog; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +public class EthereumReceiptProofCompatibilityTest { + private EthereumHcdvsService service; + private ConsensusState state; + private CrossChainMessage message; + + private static Object fixture(String name) throws Exception { + Field field = EthereumHcdvsTest.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(null); + } + + @Before public void setup() throws Exception { + service = (EthereumHcdvsService) fixture("ETHEREUM_HCDVS_SERVICE"); + state = ConsensusState.decode(((ConsensusState) fixture("CS_WHERE_MSG1")).encode()); + ConsensusState parent = ConsensusState.decode(((ConsensusState) fixture("PARENT_CS_WHERE_MSG1")).encode()); + assertTrue(service.verifyConsensusState(state, parent).isSuccess()); + message = CrossChainMessage.decode(((CrossChainMessage) fixture("MSG1")).encode()); + } + + private EthAuthMessageLog ledger() { + return EthAuthMessageLog.decodeFromJson(new String(message.getProvableData().getLedgerData())); + } + + private void ledger(EthAuthMessageLog ledger) { + message.getProvableData().setLedgerData(ledger.encodeToJson().getBytes()); + } + + @Test public void legacyGlobalIndexPassesOnlyWithOriginalProofAndMessage() { + var ledger = ledger(); + ledger.setLogIndex(99); + ledger.getSendAuthMessageLog().setLogIndex("0x63"); + ledger(ledger); + byte[] original = message.encode(); + assertTrue(service.verifyCrossChainMessage(message, state).isSuccess()); + assertArrayEquals(original, message.encode()); + } + + @Test public void explicitOutOfRangeIsRejectedEvenIfContentExists() { + var ledger = ledger(); + ledger.setLogIndex(99); + ledger.setReceiptLogIndex(99); + ledger(ledger); + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + } + + @Test public void wrongTransactionIndexAndMessageAreRejected() { + var ledger = ledger(); + ledger.getSendAuthMessageLog().setTransactionIndex("0xff"); + ledger(ledger); + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + ledger.getSendAuthMessageLog().setTransactionIndex("0x0"); + ledger(ledger); + message.setMessage(new byte[]{1, 2, 3}); + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + } + + @Test public void legacyLookupNeverBypassesTrustedReceiptRoot() { + var ledger = ledger(); + ledger.setLogIndex(99); + ledger.getSendAuthMessageLog().setLogIndex("0x63"); + ledger(ledger); + var stateJson = JSON.parseObject(new String(state.getStateData())); + var header = JSON.parseObject(stateJson.getString("execution_payload_header")); + header.put("receipts_root", "0x" + "ef".repeat(32)); + stateJson.put("execution_payload_header", header.toJSONString()); + state.setStateData(stateJson.toJSONString().getBytes()); + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + } + + @Test public void malformedProofCannotBeEndorsed() { + var proof = JSON.parseObject(new String(message.getProvableData().getProof())); + proof.put("proofRelatedNodes", java.util.List.of()); + message.getProvableData().setProof(proof.toJSONString().getBytes()); + try { + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + } catch (RuntimeException rejected) { + // The PTC caller maps invalid proof exceptions to a failed verification RPC. + } + } +} diff --git a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumHcdvsService.java b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumHcdvsService.java index bf8b29ce..19758338 100644 --- a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumHcdvsService.java +++ b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumHcdvsService.java @@ -1,16 +1,13 @@ package com.alipay.antchain.bridge.plugins.ethereum3; import java.math.BigInteger; -import java.util.Arrays; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.ObjectUtil; import com.alipay.antchain.bridge.commons.core.base.ConsensusState; import com.alipay.antchain.bridge.commons.core.base.CrossChainMessage; import com.alipay.antchain.bridge.commons.core.bta.IBlockchainTrustAnchor; -import com.alipay.antchain.bridge.plugins.ethereum3.abi.AuthMsg; import com.alipay.antchain.bridge.plugins.ethereum3.core.*; -import com.alipay.antchain.bridge.plugins.ethereum3.core.eth.EthLogTopic; import com.alipay.antchain.bridge.plugins.ethereum3.core.eth.EthReceiptProof; import com.alipay.antchain.bridge.plugins.lib.HeteroChainDataVerifierService; import com.alipay.antchain.bridge.plugins.spi.ptc.AbstractHCDVSService; @@ -18,15 +15,12 @@ import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes32; import org.hyperledger.besu.datatypes.Address; -import org.web3j.tx.Contract; import org.web3j.utils.Numeric; import tech.pegasys.teku.infrastructure.unsigned.UInt64; @HeteroChainDataVerifierService(pluginId = "plugin-ethereum3", products = "ethereum3") public class EthereumHcdvsService extends AbstractHCDVSService { - private static final EthLogTopic SEND_AUTH_MESSAGE_LOG_TOPIC = EthLogTopic.fromHexString("0x79b7516b1b7a6a39fb4b7b22e8667cd3744e5c27425292f8a9f49d1042c0c651"); - @Override public VerifyResult verifyAnchorConsensusState(IBlockchainTrustAnchor bta, ConsensusState anchorState) { getHCDVSLogger().info("verify anchor consensus state ⚓️ (slot: {}, hash: {}) for domain {} now!", @@ -181,38 +175,18 @@ public VerifyResult verifyCrossChainMessage(CrossChainMessage message, Consensus return VerifyResult.fail("receipt root not equal"); } - var ethAuthMessageLog = EthAuthMessageLog.decodeFromJson(new String(message.getProvableData().getLedgerData())); - var receiptInProof = ethReceiptProof.getEthTransactionReceipt(); - if (receiptInProof.getLogs().size() <= ethAuthMessageLog.getLogIndex()) { - getHCDVSLogger().error("❌ log index {} out of range, receipt has only {} logs", ethAuthMessageLog.getLogIndex(), receiptInProof.getLogs().size()); - return VerifyResult.fail("log index out of range"); - } - - var msgLogInProof = ethReceiptProof.getEthTransactionReceipt().getLogs().get(ethAuthMessageLog.getLogIndex()); - var msgLogInLedgerData = ethAuthMessageLog.getSendAuthMessageLog(); - - if (!SEND_AUTH_MESSAGE_LOG_TOPIC.equals(msgLogInProof.getTopics().getFirst())) { - getHCDVSLogger().error("❌ log topic in proof {} not match", msgLogInProof.getTopics().getFirst().toHexString()); - return VerifyResult.fail("log topic not match"); - } - if (!Arrays.equals(SEND_AUTH_MESSAGE_LOG_TOPIC.toArray(), Numeric.hexStringToByteArray(msgLogInLedgerData.getTopics().getFirst()))) { - getHCDVSLogger().error("❌ log topic in ledger data {} not match", msgLogInLedgerData.getTopics().getFirst()); - return VerifyResult.fail("log topic not match"); - } - if (!msgLogInProof.getLogger().equals(ethConsensusStateData.getAmContract())) { - getHCDVSLogger().error("❌ logger address in proof {} is not am contract {}", - msgLogInProof.getLogger().toHexString(), ethConsensusStateData.getAmContract().toHexString()); - return VerifyResult.fail("logger not am contract"); - } - if (!Arrays.equals(ethConsensusStateData.getAmContract().toArray(), Numeric.hexStringToByteArray(msgLogInLedgerData.getAddress()))) { - getHCDVSLogger().error("❌ logger address {} in ledger data is not am contract {}", - msgLogInLedgerData.getAddress(), ethConsensusStateData.getAmContract().toHexString()); - return VerifyResult.fail("logger not am contract"); - } - if (!Arrays.equals(msgLogInProof.getData().toArray(), Numeric.hexStringToByteArray(msgLogInLedgerData.getData()))) { - getHCDVSLogger().error("❌ log data in proof {} is not equal to ledger data {}", - msgLogInProof.getData().toHexString(), msgLogInLedgerData.getData()); - return VerifyResult.fail("log data not match"); + try { + var ledgerLog = EthAuthMessageLog.decodeFromJson(new String(message.getProvableData().getLedgerData())); + if (ledgerLog == null || ledgerLog.getSendAuthMessageLog() == null + || !BigInteger.valueOf(ethReceiptProof.getReceiptIndex()).equals( + ledgerLog.getSendAuthMessageLog().getTransactionIndex())) { + return VerifyResult.fail("receipt transaction index does not match ledger event"); + } + ledgerLog.verifyReceiptLog(ethReceiptProof.getEthTransactionReceipt().getLogs(), + ethConsensusStateData.getAmContract(), message.getMessage()); + } catch (RuntimeException e) { + // Malformed/ambiguous ledger data must fail verification, never fall back to RPC or success. + return VerifyResult.fail("invalid receipt event: {}", e.getMessage()); } getHCDVSLogger().info("🌈 crosschain message (slot: {}, txhash: {}) pass the verification", @@ -223,10 +197,6 @@ public VerifyResult verifyCrossChainMessage(CrossChainMessage message, Consensus @Override public byte[] parseMessageFromLedgerData(byte[] ledgerData) { - var eventValues = Contract.staticExtractEventParameters( - AuthMsg.SENDAUTHMESSAGE_EVENT, - EthAuthMessageLog.decodeFromJson(new String(ledgerData)).getSendAuthMessageLog() - ); - return (byte[]) eventValues.getNonIndexedValues().getFirst().getValue(); + return EthAuthMessageLog.decodeFromJson(new String(ledgerData)).decodeMessage(); } } diff --git a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/core/AcbEthClient.java b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/core/AcbEthClient.java index bac3e774..d3b7bf25 100644 --- a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/core/AcbEthClient.java +++ b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/core/AcbEthClient.java @@ -1570,7 +1570,7 @@ private List readMessagesFromEntireBlock(BeaconBlock beaconBl List finalAllReceiptsInBlock = allReceiptsInBlock; messageList.addAll(AuthMsg.getSendAuthMessageEvents(receipt).stream() - .filter(x -> StrUtil.equals(x.log.getAddress(), amContractAddressHex)) + .filter(x -> StrUtil.equalsIgnoreCase(x.log.getAddress(), amContractAddressHex)) .map( response -> CrossChainMessage.createCrossChainMessage( CrossChainMessage.CrossChainMessageType.AUTH_MSG, @@ -1578,11 +1578,7 @@ private List readMessagesFromEntireBlock(BeaconBlock beaconBl block.getTimestamp().longValue() * 1000, beaconBlock.getRoot().toArray(), response.pkg, - EthAuthMessageLog.builder() - .logIndex(response.log.getLogIndex().intValue()) - .sendAuthMessageLog(response.log) - .build() - .encodeToJson().getBytes(), + EthAuthMessageLog.fromReceipt(receipt, response.log).encodeToJson().getBytes(), getReceiptProof(finalAllReceiptsInBlock, response.log.getTransactionIndex().intValue()).encodeToJson().getBytes(), Numeric.hexStringToByteArray(receipt.getTransactionHash()) ) @@ -1621,8 +1617,8 @@ private List readMessagesByFilter(BeaconBlock beaconBlock, Bi getBbcLogger().warn("log from node has wrong contract address: {}, expected: {}", logObject.getAddress(), amContractAddressHex); continue; } - if (logObject.getTopics().size() != 1 || !StrUtil.equalsIgnoreCase(logObject.getTopics().getFirst(), SEND_AUTH_MESSAGE_LOG_TOPIC)) { - getBbcLogger().warn("log from node has wrong topic: {}, expected: {}", logObject.getTopics().getFirst(), SEND_AUTH_MESSAGE_LOG_TOPIC); + if (logObject.getTopics() == null || logObject.getTopics().size() != 1 || !StrUtil.equalsIgnoreCase(logObject.getTopics().getFirst(), SEND_AUTH_MESSAGE_LOG_TOPIC)) { + getBbcLogger().warn("log from node has wrong topics: {}, expected: {}", logObject.getTopics(), SEND_AUTH_MESSAGE_LOG_TOPIC); continue; } @@ -1636,24 +1632,18 @@ private List readMessagesByFilter(BeaconBlock beaconBlock, Bi var blockTimestamp = block.getTimestamp().longValue() * 1000; var receiptProof = getReceiptProof(allReceiptsInBlock, logObject.getTransactionIndex().intValue()); - messageList.addAll( - AuthMsg.getSendAuthMessageEvents(transactionReceipt).stream().map( - response -> CrossChainMessage.createCrossChainMessage( - CrossChainMessage.CrossChainMessageType.AUTH_MSG, - beaconBlock.getSlot().bigIntegerValue(), - blockTimestamp, - beaconBlock.getRoot().toArray(), - response.pkg, - EthAuthMessageLog.builder() - .logIndex(logObject.getLogIndex().intValue()) - .sendAuthMessageLog(logObject) - .build() - .encodeToJson().getBytes(), - receiptProof.encodeToJson().getBytes(), - Numeric.hexStringToByteArray(logObject.getTransactionHash()) - ) - ).toList() - ); + // One filter result denotes ONE event, not every AM event in the same transaction. + var ledgerLog = EthAuthMessageLog.fromReceipt(transactionReceipt, logObject); + messageList.add(CrossChainMessage.createCrossChainMessage( + CrossChainMessage.CrossChainMessageType.AUTH_MSG, + beaconBlock.getSlot().bigIntegerValue(), + blockTimestamp, + beaconBlock.getRoot().toArray(), + ledgerLog.decodeMessage(), + ledgerLog.encodeToJson().getBytes(), + receiptProof.encodeToJson().getBytes(), + Numeric.hexStringToByteArray(transactionReceipt.getTransactionHash()) + )); } if (!messageList.isEmpty()) { diff --git a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/core/EthAuthMessageLog.java b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/core/EthAuthMessageLog.java index e2c9d4de..fda8a3da 100644 --- a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/core/EthAuthMessageLog.java +++ b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/main/java/com/alipay/antchain/bridge/plugins/ethereum3/core/EthAuthMessageLog.java @@ -1,8 +1,18 @@ package com.alipay.antchain.bridge.plugins.ethereum3.core; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + import com.alibaba.fastjson.JSON; +import com.alipay.antchain.bridge.plugins.ethereum3.abi.AuthMsg; +import com.alipay.antchain.bridge.plugins.ethereum3.core.eth.EthLog; import lombok.*; +import org.hyperledger.besu.datatypes.Address; import org.web3j.protocol.core.methods.response.Log; +import org.web3j.protocol.core.methods.response.TransactionReceipt; +import org.web3j.tx.Contract; +import org.web3j.utils.Numeric; @Builder @NoArgsConstructor @@ -11,14 +21,133 @@ @Setter public class EthAuthMessageLog { + private static final String TOPIC = "0x79b7516b1b7a6a39fb4b7b22e8667cd3744e5c27425292f8a9f49d1042c0c651"; + public static EthAuthMessageLog decodeFromJson(String json) { return JSON.parseObject(json, EthAuthMessageLog.class); } + // Receipt-local for new messages. Old collectors populated this with the block-global index. private Integer logIndex; + // Explicit marker: validators must not fall back to content lookup when this is present. + private Integer receiptLogIndex; + + // Preserve the original RPC metadata, including its block-global logIndex. private Log sendAuthMessageLog; + public static EthAuthMessageLog fromReceipt(TransactionReceipt receipt, Log selected) { + require(receipt != null && receipt.getLogs() != null && selected != null, "missing receipt/log"); + require(!selected.isRemoved(), "removed log"); + require(selected.getLogIndex() != null && selected.getLogIndex().signum() >= 0 + && selected.getTransactionIndex() != null && selected.getTransactionIndex().signum() >= 0, + "missing or negative RPC index"); + require(sameHex(receipt.getTransactionHash(), selected.getTransactionHash()) + && Objects.equals(receipt.getTransactionIndex(), selected.getTransactionIndex()), + "log transaction does not match receipt"); + int found = -1; + for (int i = 0; i < receipt.getLogs().size(); i++) { + Log candidate = receipt.getLogs().get(i); + if (Objects.equals(candidate.getLogIndex(), selected.getLogIndex()) + && sameHex(candidate.getTransactionHash(), selected.getTransactionHash()) + && sameHex(candidate.getBlockHash(), selected.getBlockHash()) + && sameRpcContent(candidate, selected)) { + require(found == -1, "ambiguous RPC log"); + found = i; + } + } + require(found >= 0, "selected log missing from receipt"); + return EthAuthMessageLog.builder().logIndex(found).receiptLogIndex(found) + .sendAuthMessageLog(receipt.getLogs().get(found)).build(); + } + + public byte[] decodeMessage() { + require(sendAuthMessageLog != null && sendAuthMessageLog.getTopics() != null + && sendAuthMessageLog.getTopics().size() == 1 + && TOPIC.equalsIgnoreCase(sendAuthMessageLog.getTopics().getFirst()), "invalid AM event topic"); + var event = Contract.staticExtractEventParameters(AuthMsg.SENDAUTHMESSAGE_EVENT, sendAuthMessageLog); + require(event != null && event.getNonIndexedValues().size() == 1, "invalid AM event data"); + return (byte[]) event.getNonIndexedValues().getFirst().getValue(); + } + + /** + * Call only after validating the receipt proof against the trusted consensus receipts root. + * Legacy compatibility searches authenticated receipt content, never a node/RPC response. + */ + public EthLog verifyReceiptLog(List logs, Address amContract, byte[] message) { + require(logs != null && sendAuthMessageLog != null && logIndex != null && logIndex >= 0, + "missing or negative log index"); + require(!sendAuthMessageLog.isRemoved(), "removed log"); + require(Arrays.equals(amContract.toArray(), hex(sendAuthMessageLog.getAddress())), "logger not AM contract"); + require(Arrays.equals(message, decodeMessage()), "message does not match ledger event"); + + if (receiptLogIndex != null) { + require(receiptLogIndex.equals(logIndex) && receiptLogIndex >= 0 && receiptLogIndex < logs.size(), + "receipt log index out of range or inconsistent"); + EthLog selected = logs.get(receiptLogIndex); + require(matchesProof(selected), "indexed receipt log does not match ledger event"); + return selected; + } + + // Old producers used inconsistent index semantics. Only a UNIQUE complete event match is safe. + EthLog match = null; + int matchedIndex = -1; + for (int i = 0; i < logs.size(); i++) { + EthLog candidate = logs.get(i); + if (matchesProof(candidate)) { + require(match == null, "ambiguous legacy receipt log"); + match = candidate; + matchedIndex = i; + } + } + require(match != null, "ledger event missing from proven receipt"); + require(logIndex == matchedIndex || java.math.BigInteger.valueOf(logIndex).equals(sendAuthMessageLog.getLogIndex()), + "inconsistent legacy log index"); + return match; + } + + private boolean matchesProof(EthLog proofLog) { + if (!Arrays.equals(proofLog.getLogger().toArray(), hex(sendAuthMessageLog.getAddress())) + || !Arrays.equals(proofLog.getData().toArray(), hex(sendAuthMessageLog.getData())) + || proofLog.getTopics().size() != sendAuthMessageLog.getTopics().size()) { + return false; + } + for (int i = 0; i < proofLog.getTopics().size(); i++) { + if (!Arrays.equals(proofLog.getTopics().get(i).toArray(), hex(sendAuthMessageLog.getTopics().get(i)))) { + return false; + } + } + return true; + } + + private static boolean sameRpcContent(Log a, Log b) { + if (!sameHex(a.getAddress(), b.getAddress()) || !sameHex(a.getData(), b.getData()) + || a.getTopics() == null || b.getTopics() == null || a.getTopics().size() != b.getTopics().size()) { + return false; + } + for (int i = 0; i < a.getTopics().size(); i++) { + if (!sameHex(a.getTopics().get(i), b.getTopics().get(i))) { + return false; + } + } + return true; + } + + private static boolean sameHex(String a, String b) { + return a != null && b != null && a.equalsIgnoreCase(b); + } + + private static byte[] hex(String value) { + require(value != null && value.matches("(?i)0x(?:[0-9a-f]{2})*"), "invalid hex in ledger event"); + return Numeric.hexStringToByteArray(value); + } + + private static void require(boolean valid, String error) { + if (!valid) { + throw new IllegalArgumentException(error); + } + } + public String encodeToJson() { return JSON.toJSONString(this); } diff --git a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumCollectorLogIndexTest.java b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumCollectorLogIndexTest.java new file mode 100644 index 00000000..78e56769 --- /dev/null +++ b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumCollectorLogIndexTest.java @@ -0,0 +1,117 @@ +package com.alipay.antchain.bridge.plugins.ethereum3; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.util.List; + +import com.alipay.antchain.bridge.commons.core.base.CrossChainMessage; +import com.alipay.antchain.bridge.plugins.ethereum3.core.AcbEthClient; +import com.alipay.antchain.bridge.plugins.ethereum3.core.EthAuthMessageLog; +import com.alipay.antchain.bridge.plugins.ethereum3.core.eth.EthReceiptProof; +import org.apache.tuweni.bytes.Bytes32; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import org.web3j.protocol.Web3j; +import org.web3j.protocol.core.methods.response.*; +import tech.pegasys.teku.infrastructure.unsigned.UInt64; +import tech.pegasys.teku.spec.datastructures.blocks.BeaconBlock; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +public class EthereumCollectorLogIndexTest { + private static void field(Object object, String name, Object value) throws Exception { + Field field = object.getClass().getSuperclass().getDeclaredField(name); + field.setAccessible(true); + field.set(object, value); + } + + private static TransactionReceipt receipt(int index, Log... logs) { + TransactionReceipt receipt = new TransactionReceipt(); + receipt.setTransactionHash("0x" + (index == 0 ? "aa" : "bb").repeat(32)); + receipt.setTransactionIndex("0x" + index); + receipt.setType("0x2"); + receipt.setStatus("0x1"); + receipt.setCumulativeGasUsed("0x100"); + receipt.setLogsBloom("0x" + "00".repeat(256)); + receipt.setLogs(List.of(logs)); + for (Log log : logs) { + log.setTransactionHash(receipt.getTransactionHash()); + log.setTransactionIndex("0x" + index); + } + return receipt; + } + + private static EthLog.LogObject log(int index, String body, boolean am) { + EthLog.LogObject log = new EthLog.LogObject(); + log.setLogIndex("0x" + index); + log.setBlockHash("0x" + "cd".repeat(32)); + log.setAddress("0x" + (am ? "11" : "22").repeat(20)); + log.setTopics(List.of("0x79b7516b1b7a6a39fb4b7b22e8667cd3744e5c27425292f8a9f49d1042c0c651")); + log.setData("0x" + org.web3j.abi.FunctionEncoder.encodeConstructor(List.of( + new org.web3j.abi.datatypes.DynamicBytes(body.getBytes()))).replaceFirst("^0x", "")); + return log; + } + + @Test public void blockScanAndFilterProduceTheSameTwoDistinctEvents() throws Exception { + Web3j web3j = mock(Web3j.class, RETURNS_DEEP_STUBS); + AcbEthClient client = mock(AcbEthClient.class, CALLS_REAL_METHODS); + field(client, "web3j", web3j); + field(client, "bbcLogger", LoggerFactory.getLogger(getClass())); + + var unrelated = log(0, "unrelated", false); + var first = log(1, "first", true); + var second = log(2, "second", true); + var receipts = List.of(receipt(0, unrelated), receipt(1, first, second)); + + var rpcReceipts = new EthGetBlockReceipts(); + rpcReceipts.setResult(receipts); + when(web3j.ethGetBlockReceipts(any()).send()).thenReturn(rpcReceipts); + var rpcLogs = new EthLog(); + rpcLogs.setResult(List.of(first, second)); + when(web3j.ethGetLogs(any()).send()).thenReturn(rpcLogs); + var block = new EthBlock.Block(); + block.setTimestamp("0x123"); + var tx0 = new EthBlock.TransactionObject(); + tx0.setHash(receipts.get(0).getTransactionHash()); + var tx1 = new EthBlock.TransactionObject(); + tx1.setHash(receipts.get(1).getTransactionHash()); + block.setTransactions(List.of(tx0, tx1)); + var rpcBlock = new EthBlock(); + rpcBlock.setResult(block); + when(web3j.ethGetBlockByNumber(any(), anyBoolean()).send()).thenReturn(rpcBlock); + for (var receipt : receipts) { + var response = new EthGetTransactionReceipt(); + response.setResult(receipt); + when(web3j.ethGetTransactionReceipt(receipt.getTransactionHash()).send()).thenReturn(response); + } + BeaconBlock beacon = mock(BeaconBlock.class); + when(beacon.getSlot()).thenReturn(UInt64.valueOf(123)); + when(beacon.getRoot()).thenReturn(Bytes32.fromHexString("0x" + "ef".repeat(32))); + + List previous = null; + for (String methodName : List.of("readMessagesByFilter", "readMessagesFromEntireBlock")) { + Method method = AcbEthClient.class.getDeclaredMethod(methodName, BeaconBlock.class, BigInteger.class, String.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + var messages = (List) method.invoke(client, beacon, BigInteger.ONE, "0x" + "11".repeat(20)); + assertEquals(2, messages.size()); // The former filter path emitted 4 and mismatched payloads. + for (int i = 0; i < messages.size(); i++) { + var message = messages.get(i); + var ledger = EthAuthMessageLog.decodeFromJson(new String(message.getProvableData().getLedgerData())); + assertEquals(Integer.valueOf(i), ledger.getReceiptLogIndex()); + assertEquals(BigInteger.valueOf(i + 1), ledger.getSendAuthMessageLog().getLogIndex()); + assertArrayEquals((i == 0 ? "first" : "second").getBytes(), message.getMessage()); + var proof = EthReceiptProof.decodeFromJson(new String(message.getProvableData().getProof())); + assertEquals(1, proof.getReceiptIndex()); + assertNotNull(proof.validateAndGetRoot()); + ledger.verifyReceiptLog(proof.getEthTransactionReceipt().getLogs(), + org.hyperledger.besu.datatypes.Address.fromHexString("0x" + "11".repeat(20)), message.getMessage()); + if (previous != null) assertArrayEquals(previous.get(i).encode(), message.encode()); + } + previous = messages; + } + } +} diff --git a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumReceiptLogIndexTest.java b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumReceiptLogIndexTest.java new file mode 100644 index 00000000..3011a0b6 --- /dev/null +++ b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumReceiptLogIndexTest.java @@ -0,0 +1,158 @@ +package com.alipay.antchain.bridge.plugins.ethereum3; + +import java.math.BigInteger; +import java.util.List; +import java.nio.charset.StandardCharsets; + +import com.alipay.antchain.bridge.plugins.ethereum3.core.EthAuthMessageLog; +import com.alipay.antchain.bridge.plugins.ethereum3.core.eth.EthLog; +import com.alipay.antchain.bridge.plugins.ethereum3.core.eth.EthLogTopic; +import org.apache.tuweni.bytes.Bytes; +import org.hyperledger.besu.datatypes.Address; +import org.junit.Test; +import org.web3j.abi.FunctionEncoder; +import org.web3j.abi.datatypes.DynamicBytes; +import org.web3j.protocol.core.methods.response.Log; +import org.web3j.protocol.core.methods.response.TransactionReceipt; + +import static org.junit.Assert.*; + +public class EthereumReceiptLogIndexTest { + private static final String AM = "0x1111111111111111111111111111111111111111"; + private static final String OTHER = "0x2222222222222222222222222222222222222222"; + private static final String TOPIC = "0x79b7516b1b7a6a39fb4b7b22e8667cd3744e5c27425292f8a9f49d1042c0c651"; + + private static Log log(int globalIndex, String body) { + Log log = new Log(); + log.setLogIndex("0x" + Integer.toHexString(globalIndex)); + log.setTransactionIndex("0x1"); + log.setTransactionHash("0x" + "ab".repeat(32)); + log.setBlockHash("0x" + "cd".repeat(32)); + log.setAddress(AM); + log.setTopics(List.of(TOPIC)); + log.setData(FunctionEncoder.encodeConstructor(List.of(new DynamicBytes(body.getBytes(StandardCharsets.UTF_8))))); + if (!log.getData().startsWith("0x")) log.setData("0x" + log.getData()); + return log; + } + + private static TransactionReceipt receipt(Log... logs) { + TransactionReceipt receipt = new TransactionReceipt(); + receipt.setTransactionHash(logs[0].getTransactionHash()); + receipt.setTransactionIndex("0x1"); + receipt.setLogs(List.of(logs)); + return receipt; + } + + private static EthLog proofLog(Log log) { + return new EthLog(Address.fromHexString(log.getAddress()), Bytes.fromHexString(log.getData()), + log.getTopics().stream().map(EthLogTopic::fromHexString).toList()); + } + + private static void verify(EthAuthMessageLog ledger, List logs, String body) { + ledger.verifyReceiptLog(logs, Address.fromHexString(AM), body.getBytes(StandardCharsets.UTF_8)); + } + + @Test public void collectorUsesReceiptLocalIndexAndRetainsRpcGlobalIndex() { + Log unrelated = log(62, "unrelated"); + unrelated.setAddress(OTHER); + Log selected = log(63, "selected"); + EthAuthMessageLog ledger = EthAuthMessageLog.fromReceipt(receipt(unrelated, selected), selected); + assertEquals(Integer.valueOf(1), ledger.getLogIndex()); + assertEquals(Integer.valueOf(1), ledger.getReceiptLogIndex()); + assertEquals(BigInteger.valueOf(63), ledger.getSendAuthMessageLog().getLogIndex()); + assertArrayEquals("selected".getBytes(StandardCharsets.UTF_8), ledger.decodeMessage()); + verify(EthAuthMessageLog.decodeFromJson(ledger.encodeToJson()), List.of(proofLog(unrelated), proofLog(selected)), "selected"); + } + + @Test public void eachFilterEventResolvesOnlyItsOwnPayload() { + Log a = log(62, "one"); + Log b = log(63, "two"); + TransactionReceipt receipt = receipt(a, b); + var results = List.of(a, b).stream().map(log -> EthAuthMessageLog.fromReceipt(receipt, log)).toList(); + assertEquals(2, results.size()); + assertEquals(Integer.valueOf(0), results.get(0).getReceiptLogIndex()); + assertEquals(Integer.valueOf(1), results.get(1).getReceiptLogIndex()); + assertArrayEquals("one".getBytes(StandardCharsets.UTF_8), results.get(0).decodeMessage()); + assertArrayEquals("two".getBytes(StandardCharsets.UTF_8), results.get(1).decodeMessage()); + } + + @Test public void legacyGlobalIndexCanExceedReceiptSize() { + Log a = log(62, "one"); + var ledger = EthAuthMessageLog.builder().logIndex(62).sendAuthMessageLog(a).build(); + verify(ledger, List.of(proofLog(a), proofLog(log(63, "other"))), "one"); + assertEquals(Integer.valueOf(62), ledger.getLogIndex()); // Verification does not rewrite old ledger. + assertNull(ledger.getReceiptLogIndex()); + } + + @Test public void legacyInRangeIndexCannotSelectAnotherEvent() { + Log a = log(1, "one"); + var ledger = EthAuthMessageLog.builder().logIndex(1).sendAuthMessageLog(a).build(); + verify(ledger, List.of(proofLog(a), proofLog(log(2, "other"))), "one"); + ledger.setLogIndex(99); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(a)), "one")); + } + + @Test public void explicitInvalidIndexNeverFallsBack() { + Log a = log(62, "one"); + var ledger = EthAuthMessageLog.fromReceipt(receipt(a, log(63, "other")), a); + for (int index : new int[]{-1, 1, 62, Integer.MAX_VALUE}) { + ledger.setLogIndex(index); + ledger.setReceiptLogIndex(index); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(a), proofLog(log(63, "other"))), "one")); + } + ledger.setLogIndex(0); + ledger.setReceiptLogIndex(1); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(a)), "one")); + } + + @Test public void ambiguousLegacyEventsFailButExplicitPositionsRemainDistinct() { + Log a = log(62, "same"); + Log b = log(63, "same"); + var ledger = EthAuthMessageLog.builder().logIndex(62).sendAuthMessageLog(a).build(); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(a), proofLog(b)), "same")); + verify(EthAuthMessageLog.fromReceipt(receipt(a, b), a), List.of(proofLog(a), proofLog(b)), "same"); + verify(EthAuthMessageLog.fromReceipt(receipt(a, b), b), List.of(proofLog(a), proofLog(b)), "same"); + } + + @Test public void malformedOrTamperedEventCannotPass() { + Log a = log(62, "one"); + var ledger = EthAuthMessageLog.fromReceipt(receipt(a), a); + var proof = List.of(proofLog(a)); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "tampered")); + a.setAddress(OTHER); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + a.setAddress(AM); + a.setTopics(List.of(TOPIC, TOPIC)); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + a.setTopics(List.of()); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + a.setTopics(List.of(TOPIC)); + a.setData(log(62, "tampered").getData()); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "tampered")); + a.setData("0xzz"); + assertThrows(RuntimeException.class, () -> verify(ledger, proof, "one")); + } + + @Test public void nullNegativeRemovedAndUnprovenEventsFail() { + Log a = log(62, "one"); + var ledger = EthAuthMessageLog.builder().sendAuthMessageLog(a).build(); + var proof = List.of(proofLog(a)); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + ledger.setLogIndex(-1); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + ledger.setLogIndex(62); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, List.of(proofLog(log(63, "other"))), "one")); + a.setRemoved(true); + assertThrows(IllegalArgumentException.class, () -> verify(ledger, proof, "one")); + } + + @Test public void collectorRejectsMismatchAndAmbiguousRpcMetadata() { + Log a = log(62, "one"); + TransactionReceipt receipt = receipt(a); + assertThrows(IllegalArgumentException.class, () -> EthAuthMessageLog.fromReceipt(receipt, log(63, "one"))); + assertThrows(IllegalArgumentException.class, () -> EthAuthMessageLog.fromReceipt(receipt, log(62, "different"))); + assertThrows(IllegalArgumentException.class, () -> EthAuthMessageLog.fromReceipt(receipt(a, a), a)); + receipt.setTransactionHash("0x" + "ef".repeat(32)); + assertThrows(IllegalArgumentException.class, () -> EthAuthMessageLog.fromReceipt(receipt, a)); + } +} diff --git a/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumReceiptProofCompatibilityTest.java b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumReceiptProofCompatibilityTest.java new file mode 100644 index 00000000..afdf1cd6 --- /dev/null +++ b/acb-sdk/pluginset/ethereum3/offchain-plugin/src/test/java/com/alipay/antchain/bridge/plugins/ethereum3/EthereumReceiptProofCompatibilityTest.java @@ -0,0 +1,91 @@ +package com.alipay.antchain.bridge.plugins.ethereum3; + +import java.lang.reflect.Field; +import com.alibaba.fastjson.JSON; +import com.alipay.antchain.bridge.commons.core.base.ConsensusState; +import com.alipay.antchain.bridge.commons.core.base.CrossChainMessage; +import com.alipay.antchain.bridge.plugins.ethereum3.core.EthAuthMessageLog; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +public class EthereumReceiptProofCompatibilityTest { + private EthereumHcdvsService service; + private ConsensusState state; + private CrossChainMessage message; + + private static Object fixture(String name) throws Exception { + Field field = EthereumHcdvsTest.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(null); + } + + @Before public void setup() throws Exception { + service = (EthereumHcdvsService) fixture("ETHEREUM_HCDVS_SERVICE"); + state = ConsensusState.decode(((ConsensusState) fixture("CS_WHERE_MSG1")).encode()); + ConsensusState parent = ConsensusState.decode(((ConsensusState) fixture("PARENT_CS_WHERE_MSG1")).encode()); + assertTrue(service.verifyConsensusState(state, parent).isSuccess()); + message = CrossChainMessage.decode(((CrossChainMessage) fixture("MSG1")).encode()); + } + + private EthAuthMessageLog ledger() { + return EthAuthMessageLog.decodeFromJson(new String(message.getProvableData().getLedgerData())); + } + + private void ledger(EthAuthMessageLog ledger) { + message.getProvableData().setLedgerData(ledger.encodeToJson().getBytes()); + } + + @Test public void legacyGlobalIndexPassesOnlyWithOriginalProofAndMessage() { + var ledger = ledger(); + ledger.setLogIndex(99); + ledger.getSendAuthMessageLog().setLogIndex("0x63"); + ledger(ledger); + byte[] original = message.encode(); + assertTrue(service.verifyCrossChainMessage(message, state).isSuccess()); + assertArrayEquals(original, message.encode()); + } + + @Test public void explicitOutOfRangeIsRejectedEvenIfContentExists() { + var ledger = ledger(); + ledger.setLogIndex(99); + ledger.setReceiptLogIndex(99); + ledger(ledger); + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + } + + @Test public void wrongTransactionIndexAndMessageAreRejected() { + var ledger = ledger(); + ledger.getSendAuthMessageLog().setTransactionIndex("0xff"); + ledger(ledger); + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + ledger.getSendAuthMessageLog().setTransactionIndex("0x0"); + ledger(ledger); + message.setMessage(new byte[]{1, 2, 3}); + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + } + + @Test public void legacyLookupNeverBypassesTrustedReceiptRoot() { + var ledger = ledger(); + ledger.setLogIndex(99); + ledger.getSendAuthMessageLog().setLogIndex("0x63"); + ledger(ledger); + var stateJson = JSON.parseObject(new String(state.getStateData())); + var header = JSON.parseObject(stateJson.getString("execution_payload_header")); + header.put("receipts_root", "0x" + "ef".repeat(32)); + stateJson.put("execution_payload_header", header.toJSONString()); + state.setStateData(stateJson.toJSONString().getBytes()); + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + } + + @Test public void malformedProofCannotBeEndorsed() { + var proof = JSON.parseObject(new String(message.getProvableData().getProof())); + proof.put("proofRelatedNodes", java.util.List.of()); + message.getProvableData().setProof(proof.toJSONString().getBytes()); + try { + assertFalse(service.verifyCrossChainMessage(message, state).isSuccess()); + } catch (RuntimeException rejected) { + // The PTC caller maps invalid proof exceptions to a failed verification RPC. + } + } +} diff --git a/docs/ETHEREUM_RECEIPT_LOG_INDEX_FIX_ZH.md b/docs/ETHEREUM_RECEIPT_LOG_INDEX_FIX_ZH.md new file mode 100644 index 00000000..542a1c68 --- /dev/null +++ b/docs/ETHEREUM_RECEIPT_LOG_INDEX_FIX_ZH.md @@ -0,0 +1,77 @@ +# Ethereum 采集/PTC 收据日志索引修复 + +## 根因和边界 + +Ethereum JSON-RPC 的 Log.logIndex 在整个区块内连续编号;receipt proof 只证明一笔交易的收据, +其 logs 数组索引从 0 开始。旧 Ethereum2/3 采集器把前者存入 ledger.logIndex, +PTC 却把它当作后者,导致同块后续交易被拒绝。 + +现场原测试 UCP 2fc32b4024cc8ab23959d321a6d16c1647d21033a97a70307c88162f595126cb +存储索引2;源交易收据仅2条日志,其 RPC 索引为[2,3]。PTC 日志同时记录越界错误。 +三批32条普通消息共96条中,只有每批第一条成功,其余93条停在PENDING, +未进入Dioxide账户ISN分配。因此它是独立于ISN冲突的第二个阻塞。 + +过滤采集还有同类问题:对每一个过滤事件再遍历该收据全部AM事件,N个事件产生N²条消息, +且正文来自内层事件,ledger来自外层事件。修复后每条过滤日志只生成一条对应消息。 + +## 格式与验证规则 + +- 新ledger.logIndex、receiptLogIndex均为receipt-local位置;原sendAuthMessageLog.logIndex保留RPC区块级值。 +- 两种采集模式都从完整交易收据确定位置,核对交易哈希/索引及日志地址、topics、data、区块哈希、RPC索引。 +- PTC先验证原receipt proof并匹配可信共识receipts root,再校验receipt transaction index。 +- 显式receiptLogIndex存在时,必须与logIndex相等、范围合法、该位置的完整事件匹配;失败不回退。 +- 旧消息没有receiptLogIndex:在已验证的收据内唯一匹配地址、全部topics和data;同时检查原索引符合已匹配的局部位置或RPC全局索引。 +- AM合约必须来自可信共识;AM事件类型及解码正文必须与UCP正文一致。 +- 无匹配、同收据多个完全相同旧事件、负数/空索引、篡改字段均拒绝。不会取模、随意减偏移、选择第一项,或请求RPC代替证明。 +- 不改UCP ID、原raw_message/proof,不自动重发历史失败交易,不改任何链上合约/地址。 + +## 定向测试 + +Java21,按现有项目说明先准备Web3j生成的ABI包装类,再执行每个插件: + +```sh +mvn -f acb-sdk/pluginset/ethereum2/offchain-plugin/pom.xml \ + -Dtest=EthereumReceiptLogIndexTest,EthereumCollectorLogIndexTest,EthereumReceiptProofCompatibilityTest,EthereumHcdvsTest package +# ethereum3 使用相同测试类名,替换路径即可。 +``` + +每个插件19项:9项字段/边界测试、1项完整RPC采集双模式回归、5项完整证明兼容/篡改回归、4项原共识验证测试。 +测试证明:两个不同事件只采集两条;区块索引越界的旧事件仍须通过原证明;错误显式索引、伪造根、错误正文均不能获背书。 + +本次环境的旧Web3j生成器不认识新版Solidity发布索引linux_arm64_url字段, +构建复用了工作区中既有、Git忽略的生成ABI(Ethereum2原合约相同;Ethereum3与上次生产包一致)。 +这不改变合约,不应通过关闭证明校验解决构建问题。 + +## 部署与回滚 + +采集:Plugin Server的Ethereum2/3插件。验证:三个committee-node和一个monitor-node的同名插件。 +不替换PTC主程序、PS主程序、Relayer主程序;Dioxide插件和ISN协调表保持现状。 + +部署先备份插件、主程序与配置,暂停Relayer调度,停止目标进程后替换插件, +全部验证节点和PS就绪再恢复原Relayer。主程序短暂停止期间PTC连接Relayer嵌入BCDNS的8090端口失败属预期, +恢复后应检查消失;不能把端口监听单独视为业务验收。 + +服务器备份目录:/root/workspace2026/ethereum-log-index-20260904/backup,目录0700、含密配置0600。 +通过SHA校验的精确字节差量还原完整构建包,不是跳过验证的热补丁。 + +| 包 | SHA-256 | +| --- | --- | +| Ethereum2 | 0871c3fed47b085e509189abc50455111483a7b1e6b71d339d2d47cf5236c82b | +| Ethereum3 | f56b07c4752b289edb7fe06750bf0086239a8f807a59022365f4eb2b6e487343 | + +回滚需先暂停Relayer,逐组件停止后恢复该组件自己的备份插件,再启动PTC/PS和原Relayer。 +PTC与PS原Ethereum3版本不同,不能混用备份。不要清理bridge_tx_记录或已背书UCP; +已经提交的目标交易仍按原哈希对账,不重新发送源业务。 + +## 验收记录 + +2026-09-04:代码0840fbd已部署;原93条按正常流程继续完成,三批96/96 SUCCESS、正文各一次。 +原96条UCP的raw_message SHA-256逐条与部署前一致。93个恢复目标ISN唯一且全部FINALIZED, +分配窗口48.075秒,首分配到最后归档73.377秒。 + +新增同块普通消息两条已成功,第二条UCP e65d11850401b0ab8858ff462fa03742a832a894c429196f0d6be74d58ade0dd +保留RPC全局索引2,而新logIndex/receiptLogIndex为0,PTC及Dioxide目标执行均SUCCESS。 +另两条同块监管消息亦全部SUCCESS,PTC100%、监管四阶段完整。 +第二条UCP de523aad32be4750071747b4a858f83ac8b01301f785bf98d322e9aadc684259 +的RPC全局索引4、新局部索引1;独立FISCO SDK确认两个目标receipt均状态0、业务正文精确一致且各执行一次。 +四条新采集验收没有改变源链最终性策略。最终共享协调库229条/229个唯一ISN(181–409),全部FINALIZED。