diff --git a/core/src/main/java/com/bloxbean/cardano/yaci/core/config/YaciConfig.java b/core/src/main/java/com/bloxbean/cardano/yaci/core/config/YaciConfig.java index f0b97aac..7e3e6d45 100644 --- a/core/src/main/java/com/bloxbean/cardano/yaci/core/config/YaciConfig.java +++ b/core/src/main/java/com/bloxbean/cardano/yaci/core/config/YaciConfig.java @@ -8,12 +8,14 @@ public enum YaciConfig { private boolean returnBlockCbor; private boolean returnTxBodyCbor; + private boolean returnFullTxCbor; private boolean blockFetchCheckRangeExists = false; YaciConfig() { returnBlockCbor = false; returnTxBodyCbor = false; + returnFullTxCbor = false; } /** @@ -55,4 +57,20 @@ public void setBlockFetchCheckRangeExists(boolean blockFetchCheckRangeExists) { public boolean isBlockFetchCheckRangeExists() { return blockFetchCheckRangeExists; } + + /** + * Returns true if full transaction cbor is returned. + * @return + */ + public boolean isReturnFullTxCbor() { + return returnFullTxCbor; + } + + /** + * Set to true to return full transaction cbor. + * @param returnFullTxCbor + */ + public void setReturnFullTxCbor(boolean returnFullTxCbor) { + this.returnFullTxCbor = returnFullTxCbor; + } } diff --git a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/AuxData.java b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/AuxData.java index 6326e93e..f6e643e6 100644 --- a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/AuxData.java +++ b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/AuxData.java @@ -9,7 +9,10 @@ @NoArgsConstructor @ToString @EqualsAndHashCode +@Builder(toBuilder = true) public class AuxData { + private String cbor; + private String metadataCbor; private String metadataJson; @@ -17,4 +20,13 @@ public class AuxData { private List plutusV1Scripts; private List plutusV2Scripts; private List plutusV3Scripts; + + public AuxData(String metadataCbor, + String metadataJson, + List nativeScripts, + List plutusV1Scripts, + List plutusV2Scripts, + List plutusV3Scripts) { + this(null, metadataCbor, metadataJson, nativeScripts, plutusV1Scripts, plutusV2Scripts, plutusV3Scripts); + } } diff --git a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/Witnesses.java b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/Witnesses.java index 0d8915fe..f6508bbe 100644 --- a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/Witnesses.java +++ b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/Witnesses.java @@ -10,8 +10,10 @@ @AllArgsConstructor @EqualsAndHashCode @ToString -@Builder +@Builder(toBuilder = true) public class Witnesses { + private String cbor; + private List vkeyWitnesses = new ArrayList<>(); private List nativeScripts = new ArrayList<>(); @@ -27,4 +29,16 @@ public class Witnesses { private List plutusV2Scripts = new ArrayList<>(); private List plutusV3Scripts = new ArrayList<>(); + + public Witnesses(List vkeyWitnesses, + List nativeScripts, + List bootstrapWitnesses, + List plutusV1Scripts, + List datums, + List redeemers, + List plutusV2Scripts, + List plutusV3Scripts) { + this(null, vkeyWitnesses, nativeScripts, bootstrapWitnesses, plutusV1Scripts, + datums, redeemers, plutusV2Scripts, plutusV3Scripts); + } } diff --git a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/BlockSerializer.java b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/BlockSerializer.java index 7a9ad13b..70f92871 100644 --- a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/BlockSerializer.java +++ b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/BlockSerializer.java @@ -4,6 +4,7 @@ import com.bloxbean.cardano.yaci.core.common.EraUtil; import com.bloxbean.cardano.yaci.core.config.YaciConfig; import com.bloxbean.cardano.yaci.core.model.*; +import com.bloxbean.cardano.yaci.core.model.serializers.util.AuxDataExtractor; import com.bloxbean.cardano.yaci.core.model.serializers.util.TransactionBodyExtractor; import com.bloxbean.cardano.yaci.core.model.serializers.util.WitnessUtil; import com.bloxbean.cardano.yaci.core.protocol.Serializer; @@ -78,25 +79,62 @@ private Block deserializeBlock(DataItem di, byte[] blockBody) { witnessesSet.add(witnesses); } + List transactionWitnessRawBytes = null; + if (!witnessesSet.isEmpty()) { + try { + transactionWitnessRawBytes = WitnessUtil.getWitnessRawData(blockBody); + if (YaciConfig.INSTANCE.isReturnFullTxCbor()) { + setWitnessCbor(blockHeader.getHeaderBody().getBlockNumber(), witnessesSet, transactionWitnessRawBytes); + } + } catch (Exception e) { + //If extraction fails due to some reason + log.error("Extraction of witness bytes without serialization/deserialization failed for block : " + + blockHeader.getHeaderBody().getBlockNumber(), e); + } + } + //To fix #37 incorrect redeemer & datum hash due to cbor serialization <--> deserialization issue //Get redeemer and datum bytes directly without full deserialization try { - handleWitnessDatumRedeemer(blockHeader.getHeaderBody().getBlockNumber(), witnessesSet, blockBody); + handleWitnessDatumRedeemer(blockHeader.getHeaderBody().getBlockNumber(), witnessesSet, transactionWitnessRawBytes); } catch (Exception e) { - //If extraction fails due to some reason - log.error("Extraction of redeemer and datum bytes without serialization/deserialization failed for block : " + blockHeader.getHeaderBody().getBlockNumber()); + log.error("Extraction of redeemer and datum bytes without serialization/deserialization failed for block : " + + blockHeader.getHeaderBody().getBlockNumber(), e); } blockBuilder.transactionWitness(witnessesSet); + java.util.Map auxDataRawBytes = Collections.emptyMap(); + if (YaciConfig.INSTANCE.isReturnFullTxCbor()) { + try { + auxDataRawBytes = AuxDataExtractor.getAuxDataFromBlock(blockBody); + } catch (Exception e) { + log.error("Extraction of auxiliary data bytes failed for block : " + + blockHeader.getHeaderBody().getBlockNumber(), e); + } + } + //auxiliary data java.util.Map auxDataMap = new LinkedHashMap<>(); Map auxDataMapDI = (Map) blockArray.getDataItems().get(3); for (DataItem txIdDI: auxDataMapDI.getKeys()) { if (txIdDI == SimpleValue.BREAK) continue; + int txIndex = toInt(txIdDI); AuxData auxData = AuxDataSerializer.INSTANCE.deserializeDI(auxDataMapDI.get(txIdDI)); - auxDataMap.put(toInt(txIdDI), auxData); + if (YaciConfig.INSTANCE.isReturnFullTxCbor()) { + byte[] auxBytes = auxDataRawBytes.get(txIndex); + if (auxBytes != null && isAuxDataHashValid(blockHeader.getHeaderBody().getBlockNumber(), + txIndex, txnBodies, auxBytes)) { + auxData = auxData.toBuilder() + .cbor(HexUtil.encodeHexString(auxBytes)) + .build(); + } else if (auxBytes == null) { + log.debug("Missing raw auxiliary data bytes for block: {}, tx index: {}", + blockHeader.getHeaderBody().getBlockNumber(), txIndex); + } + } + auxDataMap.put(txIndex, auxData); } blockBuilder.auxiliaryDataMap(auxDataMap); @@ -125,9 +163,13 @@ private Block deserializeBlock(DataItem di, byte[] blockBody) { } @SneakyThrows - private void handleWitnessDatumRedeemer(long block, List witnesses, byte[] rawBlockBytes) { + private void handleWitnessDatumRedeemer(long block, List witnesses, List transactionWitness) { if (witnesses != null && !witnesses.isEmpty()) { - final List transactionWitness = WitnessUtil.getWitnessRawData(rawBlockBytes); + if (transactionWitness == null || transactionWitness.size() != witnesses.size()) { + log.error("block: {} witness set count mismatch. parsed: {}, raw: {}", + block, witnesses.size(), transactionWitness == null ? null : transactionWitness.size()); + return; + } for (int witnessIndex = 0; witnessIndex < transactionWitness.size(); witnessIndex++) { @@ -280,4 +322,40 @@ private void handleWitnessDatumRedeemer(long block, List witnesses, b } } } + + private void setWitnessCbor(long block, List witnesses, List transactionWitness) { + if (witnesses == null || transactionWitness == null || witnesses.size() != transactionWitness.size()) { + log.error("block: {} witness set count mismatch. full transaction cbor will not be available. parsed: {}, raw: {}", + block, witnesses == null ? null : witnesses.size(), + transactionWitness == null ? null : transactionWitness.size()); + return; + } + + for (int i = 0; i < witnesses.size(); i++) { + witnesses.set(i, witnesses.get(i).toBuilder() + .cbor(HexUtil.encodeHexString(transactionWitness.get(i))) + .build()); + } + } + + private boolean isAuxDataHashValid(long block, int txIndex, List txnBodies, byte[] auxBytes) { + if (txIndex >= txnBodies.size()) { + log.debug("Auxiliary data index outside transaction body list. block: {}, tx index: {}", block, txIndex); + return false; + } + + String expectedHash = txnBodies.get(txIndex).getAuxiliaryDataHash(); + if (expectedHash == null) { + return true; + } + + String actualHash = Datum.cborToHash(auxBytes); + if (!expectedHash.equals(actualHash)) { + log.debug("Auxiliary data hash mismatch : {} - {} - {} - {}", + block, txIndex, expectedHash, actualHash); + return false; + } + + return true; + } } diff --git a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/TransactionBodySerializer.java b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/TransactionBodySerializer.java index f57aa1cb..5dcb89df 100644 --- a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/TransactionBodySerializer.java +++ b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/TransactionBodySerializer.java @@ -36,7 +36,7 @@ public TransactionBody deserializeDI(DataItem di, byte[] txBytes) { String txHash = TxUtil.calculateTxHash(txBytes); transactionBodyBuilder.txHash(txHash); - if (YaciConfig.INSTANCE.isReturnTxBodyCbor()) { + if (YaciConfig.INSTANCE.isReturnTxBodyCbor() || YaciConfig.INSTANCE.isReturnFullTxCbor()) { transactionBodyBuilder.cbor(HexUtil.encodeHexString(txBytes)); } diff --git a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/util/AuxDataExtractor.java b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/util/AuxDataExtractor.java new file mode 100644 index 00000000..73f8ede2 --- /dev/null +++ b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/util/AuxDataExtractor.java @@ -0,0 +1,89 @@ +package com.bloxbean.cardano.yaci.core.model.serializers.util; + +import co.nstant.in.cbor.CborDecoder; +import co.nstant.in.cbor.CborException; +import co.nstant.in.cbor.model.DataItem; +import co.nstant.in.cbor.model.Special; +import com.bloxbean.cardano.yaci.core.util.Tuple; +import lombok.SneakyThrows; + +import java.io.ByteArrayInputStream; +import java.util.LinkedHashMap; +import java.util.Map; + +import static com.bloxbean.cardano.yaci.core.model.serializers.util.TransactionBodyExtractor.getLength; +import static com.bloxbean.cardano.yaci.core.model.serializers.util.TransactionBodyExtractor.getSymbolBytes; +import static com.bloxbean.cardano.yaci.core.util.CborSerializationUtil.toInt; + +public class AuxDataExtractor { + + @SneakyThrows + public static Map getAuxDataFromBlock(byte[] blockBytes) { + Map auxDataMap = new LinkedHashMap<>(); + ByteArrayInputStream stream = new ByteArrayInputStream(blockBytes); + CborDecoder decoder = new CborDecoder(stream); + + // era and block body array header + stream.read(); + decoder.decodeNext(); + stream.read(); + + decoder.decodeNext(); // header + decoder.decodeNext(); // transaction bodies + decoder.decodeNext(); // transaction witness sets + + int auxDataMapByte = stream.read(); + long auxDataEntryCount = getLength(auxDataMapByte, + getSymbolBytes(blockBytes.length - stream.available(), blockBytes)); + + int extraBytes = WitnessUtil.skipBytes(auxDataMapByte); + if (extraBytes > 0) { + stream.skip(extraBytes); + } + + if (auxDataEntryCount != TransactionBodyExtractor.INFINITY) { + for (int i = 0; i < auxDataEntryCount; i++) { + Tuple auxDataEntry = readAuxDataEntry(blockBytes, stream, decoder); + auxDataMap.put(auxDataEntry._1, auxDataEntry._2); + } + } else { + for (;;) { + DataItem txIndexDI = decoder.decodeNext(); + if (txIndexDI == null) { + throw new CborException("Unexpected end of stream"); + } + + if (Special.BREAK.equals(txIndexDI)) { + break; + } + + int txIndex = toInt(txIndexDI); + byte[] auxDataBytes = readNextRawValue(blockBytes, stream, decoder); + auxDataMap.put(txIndex, auxDataBytes); + } + } + + return auxDataMap; + } + + private static Tuple readAuxDataEntry(byte[] blockBytes, + ByteArrayInputStream stream, + CborDecoder decoder) throws CborException { + DataItem txIndexDI = decoder.decodeNext(); + int txIndex = toInt(txIndexDI); + return new Tuple<>(txIndex, readNextRawValue(blockBytes, stream, decoder)); + } + + private static byte[] readNextRawValue(byte[] blockBytes, + ByteArrayInputStream stream, + CborDecoder decoder) throws CborException { + int start = blockBytes.length - stream.available(); + int previous = stream.available(); + decoder.decodeNext(); + int current = stream.available(); + + byte[] rawValue = new byte[previous - current]; + System.arraycopy(blockBytes, start, rawValue, 0, rawValue.length); + return rawValue; + } +} diff --git a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/util/WitnessUtil.java b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/util/WitnessUtil.java index 53bf70f7..1bba5ebc 100644 --- a/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/util/WitnessUtil.java +++ b/core/src/main/java/com/bloxbean/cardano/yaci/core/model/serializers/util/WitnessUtil.java @@ -55,15 +55,35 @@ public static List getWitnessRawData(byte[] blockBytes) throws CborExcep List witnessList = new ArrayList<>(); - for (int i = 0; i < witnessElementCount; i++) { - final var start = blockBytes.length - stream.available(); // start cutting position - // find witness byte array length - final var previous = stream.available(); - decoder.decodeNext(); - final var current = stream.available(); - final byte[] witness = new byte[previous - current]; - System.arraycopy(blockBytes, start, witness, 0, witness.length); - witnessList.add(witness); + if (witnessElementCount != TransactionBodyExtractor.INFINITY) { + for (int i = 0; i < witnessElementCount; i++) { + final var start = blockBytes.length - stream.available(); // start cutting position + // find witness byte array length + final var previous = stream.available(); + decoder.decodeNext(); + final var current = stream.available(); + final byte[] witness = new byte[previous - current]; + System.arraycopy(blockBytes, start, witness, 0, witness.length); + witnessList.add(witness); + } + } else { + for (;;) { + final var start = blockBytes.length - stream.available(); + final var previous = stream.available(); + final var dataItem = decoder.decodeNext(); + if (dataItem == null) { + throw new CborException("Unexpected end of stream"); + } + + if (Special.BREAK.equals(dataItem)) { + break; + } + + final var current = stream.available(); + final byte[] witness = new byte[previous - current]; + System.arraycopy(blockBytes, start, witness, 0, witness.length); + witnessList.add(witness); + } } return witnessList; diff --git a/core/src/main/java/com/bloxbean/cardano/yaci/core/util/TxCborUtil.java b/core/src/main/java/com/bloxbean/cardano/yaci/core/util/TxCborUtil.java new file mode 100644 index 00000000..627b9c7b --- /dev/null +++ b/core/src/main/java/com/bloxbean/cardano/yaci/core/util/TxCborUtil.java @@ -0,0 +1,79 @@ +package com.bloxbean.cardano.yaci.core.util; + +import com.bloxbean.cardano.yaci.core.model.Era; +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +@Slf4j +public class TxCborUtil { + // Envelope bytes fixed by RFC 8949 (CBOR): 0x83/0x84 are definite-length array + // headers for 3/4 elements (major type 4: 0x80 | element count, single byte for + // counts up to 23); 0xf5/0xf4/0xf6 are the simple values true/false/null + // (major type 7). cbor-java exposes no encoded-byte constants for these + // (SimpleValue.TRUE etc. are DataItem objects), and this utility deliberately + // splices raw bytes without an encoder, so they are declared here directly. + private static final byte ARRAY_3 = (byte) 0x83; + private static final byte ARRAY_4 = (byte) 0x84; + private static final byte TRUE = (byte) 0xf5; + private static final byte FALSE = (byte) 0xf4; + private static final byte NULL = (byte) 0xf6; + + /** + * Assemble a standalone transaction CBOR envelope from raw block segment bytes. + * The outer 3/4-element definite array is convention: blocks store transaction + * bodies, witness sets, validity flags, and auxiliary data as separate segments, + * so the submitter's original outer transaction framing is not recoverable. + * + * @param era transaction era + * @param body raw transaction body bytes + * @param witnessSet raw transaction witness-set bytes + * @param auxData raw auxiliary data bytes, or null when absent + * @param isValid transaction validity flag for Alonzo and later + * @return assembled transaction CBOR, or null when required data is missing + */ + public static byte[] assembleTxCbor(Era era, + byte[] body, + byte[] witnessSet, + byte[] auxData, + boolean isValid) { + if (era == null || era == Era.Byron) { + log.debug("Cannot assemble full tx cbor for era: {}", era); + return null; + } + + if (isMissing(body) || isMissing(witnessSet)) { + log.debug("Cannot assemble full tx cbor. Missing body or witness-set bytes"); + return null; + } + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + if (era == Era.Shelley || era == Era.Allegra || era == Era.Mary) { + baos.write(ARRAY_3); + baos.write(body); + baos.write(witnessSet); + baos.write(auxData != null ? auxData : new byte[]{NULL}); + } else if (era.getValue() >= Era.Alonzo.getValue()) { + baos.write(ARRAY_4); + baos.write(body); + baos.write(witnessSet); + baos.write(isValid ? TRUE : FALSE); + baos.write(auxData != null ? auxData : new byte[]{NULL}); + } else { + log.debug("Cannot assemble full tx cbor for unsupported era: {}", era); + return null; + } + + return baos.toByteArray(); + } catch (IOException e) { + log.error("Unable to assemble full tx cbor", e); + return null; + } + } + + private static boolean isMissing(byte[] bytes) { + return bytes == null || bytes.length == 0; + } +} diff --git a/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/AuxDataExtractorTest.java b/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/AuxDataExtractorTest.java new file mode 100644 index 00000000..15de53bf --- /dev/null +++ b/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/AuxDataExtractorTest.java @@ -0,0 +1,34 @@ +package com.bloxbean.cardano.yaci.core.model.serializers; + +import com.bloxbean.cardano.yaci.core.model.serializers.util.AuxDataExtractor; +import com.bloxbean.cardano.yaci.core.util.HexUtil; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuxDataExtractorTest { + + @Test + void getAuxDataFromBlock_definiteMap() { + byte[] blockBytes = HexUtil.decodeHexString("820585808080a200a101020182030480"); + + Map auxData = AuxDataExtractor.getAuxDataFromBlock(blockBytes); + + assertThat(auxData).hasSize(2); + assertThat(HexUtil.encodeHexString(auxData.get(0))).isEqualTo("a10102"); + assertThat(HexUtil.encodeHexString(auxData.get(1))).isEqualTo("820304"); + } + + @Test + void getAuxDataFromBlock_indefiniteMap() { + byte[] blockBytes = HexUtil.decodeHexString("820585808080bf00a1010201820304ff80"); + + Map auxData = AuxDataExtractor.getAuxDataFromBlock(blockBytes); + + assertThat(auxData).hasSize(2); + assertThat(HexUtil.encodeHexString(auxData.get(0))).isEqualTo("a10102"); + assertThat(HexUtil.encodeHexString(auxData.get(1))).isEqualTo("820304"); + } +} diff --git a/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/BlockSerializerRawSegmentTest.java b/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/BlockSerializerRawSegmentTest.java new file mode 100644 index 00000000..fc97abfd --- /dev/null +++ b/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/BlockSerializerRawSegmentTest.java @@ -0,0 +1,66 @@ +package com.bloxbean.cardano.yaci.core.model.serializers; + +import com.bloxbean.cardano.yaci.core.config.YaciConfig; +import com.bloxbean.cardano.yaci.core.model.Block; +import com.bloxbean.cardano.yaci.core.model.serializers.util.AuxDataExtractor; +import com.bloxbean.cardano.yaci.core.util.CborLoader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class BlockSerializerRawSegmentTest { + + @AfterEach + void tearDown() { + YaciConfig.INSTANCE.setReturnFullTxCbor(false); + } + + @Test + void deserialize_doesNotSetAuxCborWhenAuxiliaryDataHashMismatches() { + byte[] blockBytes = CborLoader.getHexBytes("block/preprod292683.txt"); + byte[] corruptedBlockBytes = corruptAuxDataPayloadByte(blockBytes, 1); + YaciConfig.INSTANCE.setReturnFullTxCbor(true); + + Block block = BlockSerializer.INSTANCE.deserialize(corruptedBlockBytes); + + assertThat(block.getTransactionBodies().get(1).getAuxiliaryDataHash()).isNotNull(); + assertThat(block.getAuxiliaryDataMap()).containsKey(1); + assertThat(block.getAuxiliaryDataMap().get(1).getCbor()).isNull(); + assertThat(block.getAuxiliaryDataMap().get(2).getCbor()).isNotNull(); + } + + private byte[] corruptAuxDataPayloadByte(byte[] blockBytes, int txIndex) { + Map rawAuxData = AuxDataExtractor.getAuxDataFromBlock(blockBytes); + byte[] auxBytes = rawAuxData.get(txIndex); + assertThat(auxBytes).isNotNull(); + + int auxStart = indexOf(blockBytes, auxBytes); + assertThat(auxStart).isGreaterThanOrEqualTo(0); + + byte[] corrupted = Arrays.copyOf(blockBytes, blockBytes.length); + corrupted[auxStart + auxBytes.length - 1] ^= 0x01; + return corrupted; + } + + private int indexOf(byte[] source, byte[] target) { + for (int sourceIndex = 0; sourceIndex <= source.length - target.length; sourceIndex++) { + boolean matches = true; + for (int targetIndex = 0; targetIndex < target.length; targetIndex++) { + if (source[sourceIndex + targetIndex] != target[targetIndex]) { + matches = false; + break; + } + } + + if (matches) { + return sourceIndex; + } + } + + return -1; + } +} diff --git a/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/WitnessUtilTest.java b/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/WitnessUtilTest.java index cf64a780..f77a7100 100644 --- a/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/WitnessUtilTest.java +++ b/core/src/test/java/com/bloxbean/cardano/yaci/core/model/serializers/WitnessUtilTest.java @@ -168,4 +168,16 @@ public void parseRedeemerMapBytes_withExtraUintInSecondByte() { assertThat(redeemers).hasSize(30); } + + @Test + @SneakyThrows + public void getWitnessRawData_indefiniteWitnessArray() { + byte[] blockBytes = HexUtil.decodeHexString("82058580809fa0a10180ffa080"); + + var witnesses = WitnessUtil.getWitnessRawData(blockBytes); + + assertThat(witnesses).hasSize(2); + assertThat(HexUtil.encodeHexString(witnesses.get(0))).isEqualTo("a0"); + assertThat(HexUtil.encodeHexString(witnesses.get(1))).isEqualTo("a10180"); + } } diff --git a/core/src/test/java/com/bloxbean/cardano/yaci/core/util/TxCborUtilTest.java b/core/src/test/java/com/bloxbean/cardano/yaci/core/util/TxCborUtilTest.java new file mode 100644 index 00000000..0b69ef1d --- /dev/null +++ b/core/src/test/java/com/bloxbean/cardano/yaci/core/util/TxCborUtilTest.java @@ -0,0 +1,49 @@ +package com.bloxbean.cardano.yaci.core.util; + +import com.bloxbean.cardano.yaci.core.model.Era; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TxCborUtilTest { + + @Test + void assembleTxCbor_shelleyEnvelope() { + byte[] txCbor = TxCborUtil.assembleTxCbor(Era.Shelley, + HexUtil.decodeHexString("a0"), + HexUtil.decodeHexString("a0"), + null, + true); + + assertThat(HexUtil.encodeHexString(txCbor)).isEqualTo("83a0a0f6"); + } + + @Test + void assembleTxCbor_alonzoEnvelopeWithInvalidAndAuxData() { + byte[] txCbor = TxCborUtil.assembleTxCbor(Era.Alonzo, + HexUtil.decodeHexString("a0"), + HexUtil.decodeHexString("a0"), + HexUtil.decodeHexString("a10102"), + false); + + assertThat(HexUtil.encodeHexString(txCbor)).isEqualTo("84a0a0f4a10102"); + } + + @Test + void assembleTxCbor_returnsNullForMissingRequiredSegments() { + assertThat(TxCborUtil.assembleTxCbor(Era.Conway, null, HexUtil.decodeHexString("a0"), null, true)) + .isNull(); + assertThat(TxCborUtil.assembleTxCbor(Era.Conway, HexUtil.decodeHexString("a0"), null, null, true)) + .isNull(); + } + + @Test + void assembleTxCbor_returnsNullForByronAndNullEra() { + assertThat(TxCborUtil.assembleTxCbor(Era.Byron, + HexUtil.decodeHexString("a0"), HexUtil.decodeHexString("a0"), null, true)) + .isNull(); + assertThat(TxCborUtil.assembleTxCbor(null, + HexUtil.decodeHexString("a0"), HexUtil.decodeHexString("a0"), null, true)) + .isNull(); + } +} diff --git a/helper/src/main/java/com/bloxbean/cardano/yaci/helper/listener/BlockFetchAgentListenerAdapter.java b/helper/src/main/java/com/bloxbean/cardano/yaci/helper/listener/BlockFetchAgentListenerAdapter.java index aec42350..fe4a4322 100644 --- a/helper/src/main/java/com/bloxbean/cardano/yaci/helper/listener/BlockFetchAgentListenerAdapter.java +++ b/helper/src/main/java/com/bloxbean/cardano/yaci/helper/listener/BlockFetchAgentListenerAdapter.java @@ -1,19 +1,24 @@ package com.bloxbean.cardano.yaci.helper.listener; +import com.bloxbean.cardano.yaci.core.config.YaciConfig; import com.bloxbean.cardano.yaci.core.exception.BlockParseRuntimeException; import com.bloxbean.cardano.yaci.core.model.*; import com.bloxbean.cardano.yaci.core.model.byron.ByronEbBlock; import com.bloxbean.cardano.yaci.core.model.byron.ByronMainBlock; import com.bloxbean.cardano.yaci.core.protocol.blockfetch.BlockfetchAgentListener; import com.bloxbean.cardano.yaci.core.protocol.chainsync.messages.Point; +import com.bloxbean.cardano.yaci.core.util.HexUtil; +import com.bloxbean.cardano.yaci.core.util.TxCborUtil; import com.bloxbean.cardano.yaci.helper.model.Transaction; import com.bloxbean.cardano.yaci.helper.model.Utxo; +import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; +@Slf4j public class BlockFetchAgentListenerAdapter implements BlockfetchAgentListener { private BlockChainDataListener blockChainDataListener; @@ -24,10 +29,11 @@ public BlockFetchAgentListenerAdapter(BlockChainDataListener blockChainDataListe @Override public void blockFound(Block block) { List transactionEvents = new ArrayList<>(); + boolean canAssembleFullTxCbor = canAssembleFullTxCbor(block); int i = 0; for (TransactionBody txBody: block.getTransactionBodies()) { - Witnesses witnesses = block.getTransactionWitness().get(i); - AuxData auxData = block.getAuxiliaryDataMap().get(i); + Witnesses witnesses = getWitnesses(block, i); + AuxData auxData = getAuxData(block, i); boolean invalidTxn = false; if (block.getInvalidTransactions() != null && block.getInvalidTransactions().size() > 0) { @@ -42,11 +48,15 @@ public void blockFound(Block block) { utxos = Collections.emptyList(); Optional collateralReturnUtxo = getCollateralReturnUtxo(txBody); + String txCbor = canAssembleFullTxCbor + ? assembleTxCbor(block, i, txBody, witnesses, auxData, !invalidTxn) + : null; Transaction transactionEvent = Transaction.builder() .blockNumber(block.getHeader().getHeaderBody().getBlockNumber()) .slot(block.getHeader().getHeaderBody().getSlot()) .txHash(txBody.getTxHash()) + .txCbor(txCbor) .body(txBody) .utxos(utxos) .collateralReturnUtxo(collateralReturnUtxo.orElse(null)) @@ -63,6 +73,76 @@ public void blockFound(Block block) { blockChainDataListener.onBlock(block.getEra(), block, transactionEvents); } + private Witnesses getWitnesses(Block block, int txIndex) { + List transactionWitness = block.getTransactionWitness(); + if (transactionWitness == null || txIndex >= transactionWitness.size()) { + return null; + } + + return transactionWitness.get(txIndex); + } + + private AuxData getAuxData(Block block, int txIndex) { + if (block.getAuxiliaryDataMap() == null) { + return null; + } + + return block.getAuxiliaryDataMap().get(txIndex); + } + + private boolean canAssembleFullTxCbor(Block block) { + if (!YaciConfig.INSTANCE.isReturnFullTxCbor()) { + return false; + } + + List transactionBodies = block.getTransactionBodies(); + List transactionWitness = block.getTransactionWitness(); + if (transactionBodies == null || transactionWitness == null + || transactionBodies.size() != transactionWitness.size()) { + log.error("block: {} segment count mismatch. full transaction cbor will not be available. bodies: {}, witnesses: {}", + block.getHeader().getHeaderBody().getBlockNumber(), + transactionBodies == null ? null : transactionBodies.size(), + transactionWitness == null ? null : transactionWitness.size()); + return false; + } + + for (int i = 0; i < transactionBodies.size(); i++) { + // Raw witness-count mismatches leave cbor unset in BlockSerializer.setWitnessCbor, + // so this check enforces the raw-count invariant without re-reading the raw lists here. + TransactionBody txBody = transactionBodies.get(i); + Witnesses witnesses = transactionWitness.get(i); + if (txBody == null || txBody.getCbor() == null || witnesses == null || witnesses.getCbor() == null) { + log.error("block: {} missing required raw segment. full transaction cbor will not be available", + block.getHeader().getHeaderBody().getBlockNumber()); + return false; + } + } + + return true; + } + + private String assembleTxCbor(Block block, int txIndex, TransactionBody txBody, Witnesses witnesses, + AuxData auxData, boolean isValid) { + byte[] auxBytes = null; + boolean parsedAuxExists = block.getAuxiliaryDataMap() != null && block.getAuxiliaryDataMap().containsKey(txIndex); + boolean bodySaysAuxExists = txBody.getAuxiliaryDataHash() != null; + if (parsedAuxExists || bodySaysAuxExists) { + if (auxData == null || auxData.getCbor() == null) { + log.debug("block: {} tx index: {} missing raw auxiliary data bytes. full transaction cbor is null", + block.getHeader().getHeaderBody().getBlockNumber(), txIndex); + return null; + } + auxBytes = HexUtil.decodeHexString(auxData.getCbor()); + } + + byte[] fullTxCbor = TxCborUtil.assembleTxCbor(block.getEra(), + HexUtil.decodeHexString(txBody.getCbor()), + HexUtil.decodeHexString(witnesses.getCbor()), + auxBytes, + isValid); + return HexUtil.encodeHexString(fullTxCbor); + } + private List getUtxosFromOutput(TransactionBody txBody) { List utxos = new ArrayList<>(); for (int index = 0; index < txBody.getOutputs().size(); index++) { diff --git a/helper/src/main/java/com/bloxbean/cardano/yaci/helper/model/Transaction.java b/helper/src/main/java/com/bloxbean/cardano/yaci/helper/model/Transaction.java index aba512fa..ff3e6690 100644 --- a/helper/src/main/java/com/bloxbean/cardano/yaci/helper/model/Transaction.java +++ b/helper/src/main/java/com/bloxbean/cardano/yaci/helper/model/Transaction.java @@ -18,6 +18,7 @@ public class Transaction { private long blockNumber; private long slot; private String txHash; + private String txCbor; private TransactionBody body; private List utxos; private Utxo collateralReturnUtxo; diff --git a/helper/src/test/java/com/bloxbean/cardano/yaci/helper/listener/BlockFetchAgentListenerAdapterTest.java b/helper/src/test/java/com/bloxbean/cardano/yaci/helper/listener/BlockFetchAgentListenerAdapterTest.java new file mode 100644 index 00000000..07e5a0d6 --- /dev/null +++ b/helper/src/test/java/com/bloxbean/cardano/yaci/helper/listener/BlockFetchAgentListenerAdapterTest.java @@ -0,0 +1,231 @@ +package com.bloxbean.cardano.yaci.helper.listener; + +import co.nstant.in.cbor.CborDecoder; +import co.nstant.in.cbor.CborException; +import co.nstant.in.cbor.model.DataItem; +import com.bloxbean.cardano.yaci.core.config.YaciConfig; +import com.bloxbean.cardano.yaci.core.model.AuxData; +import com.bloxbean.cardano.yaci.core.model.Block; +import com.bloxbean.cardano.yaci.core.model.BlockHeader; +import com.bloxbean.cardano.yaci.core.model.Era; +import com.bloxbean.cardano.yaci.core.model.HeaderBody; +import com.bloxbean.cardano.yaci.core.model.TransactionBody; +import com.bloxbean.cardano.yaci.core.model.Witnesses; +import com.bloxbean.cardano.yaci.core.model.serializers.BlockSerializer; +import com.bloxbean.cardano.yaci.core.model.serializers.util.AuxDataExtractor; +import com.bloxbean.cardano.yaci.core.model.serializers.util.TransactionBodyExtractor; +import com.bloxbean.cardano.yaci.core.model.serializers.util.WitnessUtil; +import com.bloxbean.cardano.yaci.core.util.HexUtil; +import com.bloxbean.cardano.yaci.core.util.Tuple; +import com.bloxbean.cardano.yaci.helper.model.Transaction; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class BlockFetchAgentListenerAdapterTest { + + @AfterEach + void tearDown() { + YaciConfig.INSTANCE.setReturnFullTxCbor(false); + } + + @Test + void blockFound_populatesTxCborWhenRawSegmentsExist() { + YaciConfig.INSTANCE.setReturnFullTxCbor(true); + CapturingListener listener = new CapturingListener(); + BlockFetchAgentListenerAdapter adapter = new BlockFetchAgentListenerAdapter(listener); + + adapter.blockFound(block(List.of(txBody("tx1", null)), List.of(witness("a0")), + Collections.emptyMap(), Collections.emptyList())); + + assertThat(listener.transactions).hasSize(1); + assertThat(listener.transactions.get(0).getTxCbor()).isEqualTo("84a0a0f5f6"); + } + + @Test + void blockFound_usesInvalidTransactionFlag() { + YaciConfig.INSTANCE.setReturnFullTxCbor(true); + CapturingListener listener = new CapturingListener(); + BlockFetchAgentListenerAdapter adapter = new BlockFetchAgentListenerAdapter(listener); + + adapter.blockFound(block(List.of(txBody("tx1", null)), List.of(witness("a0")), + Collections.emptyMap(), List.of(0))); + + assertThat(listener.transactions).hasSize(1); + assertThat(listener.transactions.get(0).getTxCbor()).isEqualTo("84a0a0f4f6"); + } + + @Test + void blockFound_returnsNullTxCborWhenAuxDataRawBytesMissing() { + YaciConfig.INSTANCE.setReturnFullTxCbor(true); + CapturingListener listener = new CapturingListener(); + BlockFetchAgentListenerAdapter adapter = new BlockFetchAgentListenerAdapter(listener); + Map auxData = new LinkedHashMap<>(); + auxData.put(0, AuxData.builder().metadataCbor("a0").build()); + + adapter.blockFound(block(List.of(txBody("tx1", null)), List.of(witness("a0")), + auxData, Collections.emptyList())); + + assertThat(listener.transactions).hasSize(1); + assertThat(listener.transactions.get(0).getTxCbor()).isNull(); + } + + @Test + void blockFound_skipsWholeBlockWhenRequiredSegmentMissing() { + YaciConfig.INSTANCE.setReturnFullTxCbor(true); + CapturingListener listener = new CapturingListener(); + BlockFetchAgentListenerAdapter adapter = new BlockFetchAgentListenerAdapter(listener); + + adapter.blockFound(block( + List.of(txBody("tx1", null), txBody("tx2", null)), + List.of(witness("a0"), witness(null)), + Collections.emptyMap(), + Collections.emptyList())); + + assertThat(listener.transactions).hasSize(2); + assertThat(listener.transactions).allMatch(tx -> tx.getTxCbor() == null); + } + + @Test + void blockFound_skipsWholeBlockWhenSegmentCountsMismatch() { + YaciConfig.INSTANCE.setReturnFullTxCbor(true); + CapturingListener listener = new CapturingListener(); + BlockFetchAgentListenerAdapter adapter = new BlockFetchAgentListenerAdapter(listener); + + adapter.blockFound(block( + List.of(txBody("tx1", null), txBody("tx2", null)), + List.of(witness("a0")), + Collections.emptyMap(), + Collections.emptyList())); + + assertThat(listener.transactions).hasSize(2); + assertThat(listener.transactions).allMatch(tx -> tx.getTxCbor() == null); + } + + @Test + void blockFound_populatesTxCborFromRealBlockFixture() throws Exception { + YaciConfig.INSTANCE.setReturnFullTxCbor(true); + byte[] blockBytes = loadBlockFixture("preprod292683.txt"); + Block block = BlockSerializer.INSTANCE.deserialize(blockBytes); + CapturingListener listener = new CapturingListener(); + + new BlockFetchAgentListenerAdapter(listener).blockFound(block); + + List> rawBodies = TransactionBodyExtractor.getTxBodiesFromBlock(blockBytes); + List rawWitnesses = WitnessUtil.getWitnessRawData(blockBytes); + Map rawAuxData = AuxDataExtractor.getAuxDataFromBlock(blockBytes); + assertThat(block.getTransactionWitness()).anyMatch(this::hasPlutusWitnessContent); + assertThat(rawAuxData).isNotEmpty(); + assertThat(listener.transactions).hasSize(rawBodies.size()); + assertThat(rawWitnesses).hasSize(rawBodies.size()); + assertThat(listener.transactions).allSatisfy(tx -> assertThat(tx.getTxCbor()).isNotNull()); + + for (int txIndex = 0; txIndex < listener.transactions.size(); txIndex++) { + byte[] txBytes = HexUtil.decodeHexString(listener.transactions.get(txIndex).getTxCbor()); + assertThat(com.bloxbean.cardano.client.transaction.spec.Transaction.deserialize(txBytes)).isNotNull(); + + List txItems = topLevelItemBytes(txBytes); + assertThat(txItems).hasSize(4); + assertThat(HexUtil.encodeHexString(txItems.get(0))) + .isEqualTo(HexUtil.encodeHexString(rawBodies.get(txIndex)._2)); + assertThat(HexUtil.encodeHexString(txItems.get(1))) + .isEqualTo(HexUtil.encodeHexString(rawWitnesses.get(txIndex))); + assertThat(txItems.get(2)).containsExactly(isValidTx(block, txIndex) ? (byte) 0xf5 : (byte) 0xf4); + assertThat(HexUtil.encodeHexString(txItems.get(3))).isEqualTo( + HexUtil.encodeHexString(rawAuxData.getOrDefault(txIndex, new byte[] {(byte) 0xf6}))); + } + } + + private Block block(List bodies, List witnesses, + Map auxData, List invalidTransactions) { + return Block.builder() + .era(Era.Alonzo) + .header(BlockHeader.builder() + .headerBody(HeaderBody.builder() + .blockNumber(100) + .slot(200) + .build()) + .build()) + .transactionBodies(bodies) + .transactionWitness(witnesses) + .auxiliaryDataMap(auxData) + .invalidTransactions(invalidTransactions) + .build(); + } + + private TransactionBody txBody(String txHash, String auxDataHash) { + return TransactionBody.builder() + .txHash(txHash) + .cbor("a0") + .outputs(Collections.emptyList()) + .auxiliaryDataHash(auxDataHash) + .build(); + } + + private Witnesses witness(String cbor) { + return Witnesses.builder() + .cbor(cbor) + .build(); + } + + private byte[] loadBlockFixture(String fileName) throws Exception { + try (InputStream resource = getClass().getClassLoader().getResourceAsStream("block/" + fileName)) { + assertThat(resource).isNotNull(); + return HexUtil.decodeHexString(new String(resource.readAllBytes()).trim()); + } + } + + private boolean isValidTx(Block block, int txIndex) { + return block.getInvalidTransactions() == null || !block.getInvalidTransactions().contains(txIndex); + } + + private boolean hasPlutusWitnessContent(Witnesses witnesses) { + return hasItems(witnesses.getPlutusV1Scripts()) + || hasItems(witnesses.getPlutusV2Scripts()) + || hasItems(witnesses.getPlutusV3Scripts()) + || hasItems(witnesses.getDatums()) + || hasItems(witnesses.getRedeemers()); + } + + private boolean hasItems(List items) { + return items != null && !items.isEmpty(); + } + + private List topLevelItemBytes(byte[] txBytes) throws CborException { + ByteArrayInputStream stream = new ByteArrayInputStream(txBytes); + int arrayHeader = stream.read(); + assertThat(arrayHeader).isEqualTo(0x84); + + CborDecoder decoder = new CborDecoder(stream); + List items = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + int start = txBytes.length - stream.available(); + int before = stream.available(); + decoder.decodeNext(); + int length = before - stream.available(); + items.add(Arrays.copyOfRange(txBytes, start, start + length)); + } + + assertThat(stream.available()).isZero(); + return items; + } + + private static class CapturingListener implements BlockChainDataListener { + private List transactions = new ArrayList<>(); + + @Override + public void onBlock(Era era, Block block, List transactions) { + this.transactions = transactions; + } + } +} diff --git a/helper/src/test/resources/block/preprod292683.txt b/helper/src/test/resources/block/preprod292683.txt new file mode 100644 index 00000000..f4c4849d --- /dev/null +++ b/helper/src/test/resources/block/preprod292683.txt @@ -0,0 +1 @@ +820685828a1a0004774b1a00c812a55820bce3f7f3ff4cf2dc738ddbdbf48b7fd9e6db555e1e52023d02e0e2c3fafdb1085820a9d974fd26bfaf385749113f260271430276bed6ef4dad6968535de6778471ce582009e142413e6a20c48dcf0bc1e1604d22ec6c1682802212c130bd8b0888fa925a825840e0f1c534ae6b79ae644dd7ef2f9bd21f47fe0749fd40f38ecf9eed1afb14eb69dea586c942bb52c7fa81788f3c6e2a01c4cf22069b787a807fb68436914630785850f0af948a71ee57ee04187960dbce939b1461d908659b45a41d4719f43a6d4d8ac5cd72aecf92b7538d1000d0c5ec4775894c8bbcbe925091986a49fb16c1095b4b18d99df2cca20ba42f84ef79104c0b19125d5820319edbebbf647653f9fb20bfda0b68f628ff30c50b4ea97fdee61c88bd29ae7484582001a2a5d502467f061867fd59881bd2ddf11bb1d287c82d56692dd68c5428ec6c01183c5840ba5be0d6705c112f3a5064bfc538fe44356dcdcd83c3708b7511d644378075edf75008c1ab928028057e8101575ae4444db0067a37ea2382842886d52d1476048208005901c0fadcd3eebcf750c974620223ba4d6e173594af513bee52c3db7f89a8f46bc47746706d5f0e6b6e7293684cb8bfa57741658528fe7eb02956b6a3fab732e87c0295a604abf13bd575a02db4cb6ed603bee87226b0364d72bce927a12b60dfb0220736f2bbd74416ae6009ec8f198dfb3dafdc0d915f9967b2954053b1d302de6bee11e9832513d5b9857b4f253e287c36fc4688d0dd373dcc6a41300c3aebe1b2fed63c4e4d0efcdab4616d123146b1f3e5747943d7f604d9e4f57becdefdccab4769eb6723d569de3d0d1dae38f62f10c50754c336d6b3f5b9e29447cc612deea5ed92018d7b68d374a4505b9c40b6e0e6af98f3482212ee97c322fb1e3cadbdbc0c718a25dc0d6f31a620f533e99f3998af303be857a71288a553aa24531711f1e7bf93a69b20c2eedfd8205d366f978f69d632ecd720f53b475bf5f7a133ce2ad5129b42f717fac66e5e0e31db2b1b6761f6522fb97c4b824e02af0fd2eb58507bb50a78b56b46a92d2fb9c4baa72722e91fab8557c24c2cd3a2f032005b91eb1c01519b77f6b0cf016dde0775e6f6a8bfebadb8730b7dae767d3283321e012401834e2f59520e5e15dfa8612457c81c4d5da8a4af9ffdd2e8f0176ad4f48083a60081825820f236457cb660302bfcfb987a4c0966bd0177cae24ca47a0886774e464dfa977d000181825839001317374d7835d83ef42a9fb05990f620b637e59a0a1d34123aa30d72b3a4c08b5e2d4114f4543d2b47e0654ba5864b848ae560da9b8984421a016fbca0021a000dbba00b5820d85796446dd1e81843a028163b8a62e686af4eb504ebe8b2378494e85802c8960d8282582038f4876b62fdad0e8309bf0177ab55c4da638f3c354bad6317d2953a175d05bb018258202fe916f5c881b9fd310b6a29ae631fcebd980019783751e69cdfb51299e98f1a000e81581c1317374d7835d83ef42a9fb05990f620b637e59a0a1d34123aa30d72a60081825820ef3166f9d77252bfea355c380e631e8bc46c7c597b812647e7cc0a9c824f73da00018382583900d0c00f716cae38b77edd19bced2c0efbc4550fb83f389d0cd4039438acea7f16d980190ca50bb924e0795133c829043f2a304e878c273173821a001e8480a1581c4ea8f87e5283f6aaae00e72c48b7c77bd45196272bcac69e463fa82ba1581c7265696369656e646973496e7465726e616c496e7472616e65744469018258390005acdfa65952ac8a7b0cf168d4912535b117f5998967ee742644dcc405bc59d947656b4167d84497085d372b58f74b2057c8c542a11c22be1a001e848082583900c010c94c93bbe65283293a9946719b9277b325a0ab07e787b78572bac151e09314abc3031eae0f450ac4f3bbac851b9947ab4651433d47a81a001b3b1b021a00034965031a00c8d5d609a1581c4ea8f87e5283f6aaae00e72c48b7c77bd45196272bcac69e463fa82ba1581c7265696369656e646973496e7465726e616c496e7472616e6574446901075820a31ba6b5060a619728467518647834753fe5609e669873dfec50f57b38dadc6fa60081825820fb2a40e945a3275629f8060c3bbeb13951cda7e3924c2fc4113c40481a373ec000018382583900d0c00f716cae38b77edd19bced2c0efbc4550fb83f389d0cd4039438acea7f16d980190ca50bb924e0795133c829043f2a304e878c273173821a001e8480a1581ce2ce294346e5fb015a680c82b5bb111e3477fa533580d19e3965dbc8a1581c6c61626f72696f73616d4675747572654f7074696d697a6174696f6e018258390005acdfa65952ac8a7b0cf168d4912535b117f5998967ee742644dcc405bc59d947656b4167d84497085d372b58f74b2057c8c542a11c22be1a001e848082583900c010c94c93bbe65283293a9946719b9277b325a0ab07e787b78572bac151e09314abc3031eae0f450ac4f3bbac851b9947ab4651433d47a81a001b37ab021a00034cd5031a00c8d5d609a1581ce2ce294346e5fb015a680c82b5bb111e3477fa533580d19e3965dbc8a1581c6c61626f72696f73616d4675747572654f7074696d697a6174696f6e010758201bb6137008f7b6b5e71bc55afbdd7c8318c8c39e58ecbc11341679d0e63e660883a400838258205fef2354d0a1878727c1b6d82dc6a7a827097566c0932060a9ff5fd2fdd447205840cbd5e18445db06fb11fe0ed6166b8f49f1a2bd65112115e26add1833d25d2e28775a42490f56640bd14a2953b8692cc3132fd38dddcfa457f586399b7ca2c20d82582067abb0e98d1ac61d1ddde5cbb35ae9bc50d456fa7db321e22bb135ccce80cdd558409df86f4dd0f66f4d542e6a6766d9e957796f3ac3312be635fa864b63b6886dc1e57cc5e38262d09f62bfb80fda6101dd2dbd3f6e62718013899691255eb73c03825820ff377ff64187b9604b6da869490f18baa01397fdeeca6bec7d31e458161880c45840a765418d8953b8391c1552f11cea101537e8f1e39feb753e150b1a86926a4534e2b2920a9e0e06e69e39277a5b394efd10730452aacb71fc72f896cd2dd78c01038159072d59072a01000033232323232323232323232323232332232323232222232325335333006375c00a6eb8010cccd5cd19b8735573aa004900011991091980080180119191919191919191919191999ab9a3370e6aae754029200023333333333222222222212333333333300100b00a009008007006005004003002335014232323333573466e1cd55cea80124000466442466002006004603e6ae854008c064d5d09aba2500223263202833573805205004c26aae7940044dd50009aba1500a33501401535742a012666aa02eeb94058d5d0a804199aa80bbae501635742a00e66a02803e6ae854018cd4050cd54088081d69aba150053232323333573466e1cd55cea801240004664424660020060046464646666ae68cdc39aab9d5002480008cc8848cc00400c008cd4095d69aba150023026357426ae8940088c98c80b0cd5ce01681601509aab9e5001137540026ae854008c8c8c8cccd5cd19b8735573aa004900011991091980080180119a812bad35742a004604c6ae84d5d1280111931901619ab9c02d02c02a135573ca00226ea8004d5d09aba2500223263202833573805205004c26aae7940044dd50009aba1500433501475c6ae85400ccd4050cd54089d710009aba15002301c357426ae8940088c98c8090cd5ce01281201109aba25001135744a00226ae8940044d5d1280089aba25001135744a00226ae8940044d5d1280089aab9e5001137540026ae854008c8c8c8cccd5cd19b875001480188c848888c010014c05cd5d09aab9e500323333573466e1d400920042321222230020053019357426aae7940108cccd5cd19b875003480088c848888c004014c054d5d09aab9e500523333573466e1d40112000232122223003005375c6ae84d55cf280311931900f99ab9c02001f01d01c01b01a135573aa00226ea8004d5d09aba2500223263201833573803203002c202e264c6402e66ae7124010350543500017135573ca00226ea800448c88c008dd6000990009aa80a111999aab9f00125009233500830043574200460066ae8800804c8c8c8c8cccd5cd19b8735573aa00690001199911091998008020018011919191999ab9a3370e6aae7540092000233221233001003002301535742a00466a01c0286ae84d5d1280111931900c19ab9c019018016135573ca00226ea8004d5d0a801999aa803bae500635742a00466a014eb8d5d09aba2500223263201433573802a02802426ae8940044d55cf280089baa0011335500175ceb44488c88c008dd5800990009aa80911191999aab9f0022500823350073355015300635573aa004600a6aae794008c010d5d100180909aba100111220021221223300100400312232323333573466e1d4005200023212230020033005357426aae79400c8cccd5cd19b8750024800884880048c98c8040cd5ce00880800700689aab9d500113754002464646666ae68cdc39aab9d5002480008cc8848cc00400c008c014d5d0a8011bad357426ae8940088c98c8034cd5ce00700680589aab9e5001137540024646666ae68cdc39aab9d5001480008dd71aba135573ca004464c6401666ae7003002c0244dd500089119191999ab9a3370ea00290021091100091999ab9a3370ea00490011190911180180218031aba135573ca00846666ae68cdc3a801a400042444004464c6401c66ae7003c03803002c0284d55cea80089baa0012323333573466e1d40052002212200223333573466e1d40092000212200123263200a33573801601401000e26aae74dd5000919191919191999ab9a3370ea002900610911111100191999ab9a3370ea004900510911111100211999ab9a3370ea00690041199109111111198008048041bae35742a00a6eb4d5d09aba2500523333573466e1d40112006233221222222233002009008375c6ae85401cdd71aba135744a00e46666ae68cdc3a802a400846644244444446600c01201060186ae854024dd71aba135744a01246666ae68cdc3a8032400446424444444600e010601a6ae84d55cf280591999ab9a3370ea00e900011909111111180280418071aba135573ca018464c6402466ae7004c04804003c03803403002c0284d55cea80209aab9e5003135573ca00426aae7940044dd50009191919191999ab9a3370ea002900111999110911998008028020019bad35742a0086eb4d5d0a8019bad357426ae89400c8cccd5cd19b875002480008c8488c00800cc020d5d09aab9e500623263200b33573801801601201026aae75400c4d5d1280089aab9e500113754002464646666ae68cdc3a800a400446424460020066eb8d5d09aab9e500323333573466e1d400920002321223002003375c6ae84d55cf280211931900419ab9c009008006005135573aa00226ea800444888c8c8cccd5cd19b8735573aa0049000119aa80518031aba150023005357426ae8940088c98c8020cd5ce00480400309aab9e5001137540029309000a490350543100112212330010030021123230010012233003300200200122232333573466e3c010004488008488004dc90011049f5820e352e1187f3ce0f3fbf583f50feb5531f44e2a8f7fc2d6c492947a2a9106efb0ff05818400005f58407b226e616d65223a224c75636b796d696e222c226d61784e6f223a35352c226d617843686f69636573223a352c2273656c6563746564223a5b362c32302c3334482c34382c34395d7dff821a006acfc01ab2d05e00a2008282582066adae7f87a807f46c446804dfd979e08ab93be38dac01a0cbd6bffa19a267d65840b62b4da36c56afb92b03f229981554217aee7b5474a59cbc9290ec4df78874162112b47520fee0d75f4dc27e1646447ce9bdb02bf3587de492cfaa10a3662b01825820e464197801d220c7f31b0be59bffb19b153566e582148644ea1511fdcef6eeb65840098eda098d7c99da9801e542932a6635b2eb5462c85a207c06c4a61f692cb1dd97561ae7b6f688665bfbb8adeff01b22d17da5900e245df18d064f69404b3c0801818201818200581c22c7d6d28f26b2db5362162747f2f0142c994586b5d4d941824d0d81a20082825820fecad0ded80d56ba9236d324be8ecd9d617f8efee5b9538445f2c83e325d9f6c58408aade9803004e8255aa279c37e3f32a9bd0b64cb173604dbc6b464c875736d093c0480e973bff43f0f4127c05e819751d0e67538dfe7eeb79995ffbfa21db3018258204836739d87ee20d1d573e296ede45c8d09cdeb3c3c53cff67382a87c128c22b55840a23caa015bf1cbdba12a635e25cc1745fcb906ccf758c6ec73679cbe342716557bec79e5f672d591199ab281259a8bf5e60cdc53578d57309225ca94cd00870f01818201818200581c43e74a6a7a6c56789b8ac7e9630eace0bef260d0a86ecf720cd58241a201d90103a100a11902d1a278383465613866383765353238336636616161653030653732633438623763373762643435313936323732626361633639653436336661383262a1781c7265696369656e646973496e7465726e616c496e7472616e65744469a663346574634144506b6465736372697074696f6e782f4920736177206f6e65206f6620746865736520696e20416c676572696120616e64204920626f75676874206f6e652e6566696c657381a3696d656469615479706569696d6167652f706e67646e616d65781c7265696369656e646973496e7465726e616c496e7472616e65744469637372637835697066733a2f2f516d4e5156766a557763315048354c736f38416d3273334142507a634d554250434c445a694a5a436e543943674365696d6167657835697066733a2f2f516d535772363350784744515155505a3456753532464a784a3238754a4532424a70467774506774737378595947696d656469615479706569696d6167652f706e67646e616d65781c7265696369656e646973496e7465726e616c496e7472616e657444696776657273696f6e63312e3002d90103a100a11902d1a278386532636532393433343665356662303135613638306338326235626231313165333437376661353333353830643139653339363564626338a1781c6c61626f72696f73616d4675747572654f7074696d697a6174696f6ea66e38367065727370696369617469737819496e74656c6c6967656e7420536f667420436f6d70757465726b6465736372697074696f6e78214d7920706561636f636b206c6f76657320746f20706c617920776974682069742e6566696c657381a3696d656469615479706569696d6167652f706e67646e616d65781c6c61626f72696f73616d4675747572654f7074696d697a6174696f6e637372637835697066733a2f2f516d63314671563541765561517a6f6779344b3956366e566a5a464c6f76586f587a6e796e353441556f5056555265696d6167657835697066733a2f2f516d53576f71383666717159754341615468317065526953526446675a683435694a68625638664c446675706372696d656469615479706569696d6167652f706e67646e616d65781c6c61626f72696f73616d4675747572654f7074696d697a6174696f6e6776657273696f6e63312e3080 \ No newline at end of file