diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 0482643d8d0..f742229601d 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -270,6 +270,8 @@ public class Wallet { "BurnNewLeaf(uint256,bytes32,bytes32,bytes32,bytes32[21])")); private static final byte[] SHIELDED_TRC20_LOG_TOPICS_BURN_TOKEN = Hash.sha3(ByteArray .fromString("TokenBurn(address,uint256,bytes32[3])")); + private static final byte[] SHIELDED_TRC20_LOG_TOPICS_NOTE_SPENT = Hash.sha3(ByteArray + .fromString("NoteSpent(bytes32)")); private static final String BROADCAST_TRANS_FAILED = "Broadcast transaction {} failed, {}."; @Getter @@ -3672,9 +3674,7 @@ public ShieldedTRC20Parameters createShieldedContractParameters( builder.setTransparentToAddress(transparentToAddressTvm); builder.setTransparentToAmount(toAmount); - Optional cipher = NoteEncryption.Encryption - .encryptBurnMessageByOvk(ovk, toAmount, transparentToAddress); - cipher.ifPresent(builder::setBurnCiphertext); + builder.setOvk(ovk); ExpandedSpendingKey expsk = new ExpandedSpendingKey(ask, nsk, ovk); GrpcAPI.SpendNoteTRC20 spendNote = shieldedSpends.get(0); @@ -3799,9 +3799,7 @@ public ShieldedTRC20Parameters createShieldedContractParametersWithoutAsk( System.arraycopy(transparentToAddress, 1, transparentToAddressTvm, 0, 20); builder.setTransparentToAddress(transparentToAddressTvm); builder.setTransparentToAmount(toAmount); - Optional cipher = NoteEncryption.Encryption - .encryptBurnMessageByOvk(ovk, toAmount, transparentToAddress); - cipher.ifPresent(builder::setBurnCiphertext); + builder.setOvk(ovk); GrpcAPI.SpendNoteTRC20 spendNote = shieldedSpends.get(0); buildShieldedTRC20InputWithAK(builder, spendNote, ak, nsk); if (receiveSize == 1) { @@ -3838,6 +3836,8 @@ private int getShieldedTRC20LogType(TransactionInfo.Log log, byte[] contractAddr return 3; } else if (Arrays.equals(topicsBytes, SHIELDED_TRC20_LOG_TOPICS_BURN_TOKEN)) { return 4; + } else if (Arrays.equals(topicsBytes, SHIELDED_TRC20_LOG_TOPICS_NOTE_SPENT)) { + return 5; } } return 0; @@ -3909,7 +3909,9 @@ private DecryptNotesTRC20 queryTRC20NoteByIvk(long startNum, long endNum, int index = 0; for (TransactionInfo.Log log : logList) { int logType = getShieldedTRC20LogType(log, shieldedTRC20ContractAddress); - if (logType > 0) { + // Only note-producing log types (1..3) advance the note index; + // TokenBurn (4) and NoteSpent (5) do not emit a leaf. + if (logType > 0 && logType < 4) { noteBuilder = DecryptNotesTRC20.NoteTx.newBuilder(); noteBuilder.setTxid(ByteString.copyFrom(txId)); noteBuilder.setIndex(index); @@ -4001,7 +4003,8 @@ public DecryptNotesTRC20 scanShieldedTRC20NotesByIvk( private Optional getNoteTxFromLogListByOvk( DecryptNotesTRC20.NoteTx.Builder builder, - TransactionInfo.Log log, byte[] ovk, int logType) throws ZksnarkException { + TransactionInfo.Log log, byte[] ovk, int logType, byte[] pendingNf) + throws ZksnarkException { byte[] logData = log.getData().toByteArray(); if (!ArrayUtils.isEmpty(logData)) { if (logType > 0 && logType < 4) { @@ -4040,18 +4043,32 @@ private Optional getNoteTxFromLogListByOvk( } } } else if (logType == 4) { - //Data = toAddress(32) + value(32) + ciphertext(80) + padding(16) + // Data = toAddress(32) + value(32) + cipher(80) + nonce(12) + reserved/version(4) + if (logData.length < 64 + NoteEncryption.Encryption.BURN_CIPHER_RECORD_SIZE) { + return Optional.empty(); + } byte[] logToAddress = ByteArray.subArray(logData, 12, 32); byte[] logAmountArray = ByteArray.subArray(logData, 32, 64); byte[] cipher = ByteArray.subArray(logData, 64, 144); + byte[] nonceFromLog = ByteArray.subArray(logData, 144, + 144 + NoteEncryption.Encryption.BURN_NONCE_LEN); + byte[] reservedFromLog = ByteArray.subArray(logData, + 144 + NoteEncryption.Encryption.BURN_NONCE_LEN, + 144 + NoteEncryption.Encryption.BURN_NONCE_LEN + + NoteEncryption.Encryption.BURN_RESERVED_LEN); BigInteger logAmount = ByteUtil.bytesToBigInteger(logAmountArray); byte[] plaintext; byte[] amountArray = new byte[32]; byte[] decryptedAddress = new byte[20]; + Optional decryptedText = NoteEncryption.Encryption - .decryptBurnMessageByOvk(ovk, cipher); + .decryptBurnMessageByOvk(ovk, cipher, nonceFromLog, reservedFromLog, pendingNf); + if (decryptedText.isPresent()) { plaintext = decryptedText.get(); + if (plaintext[32] != Wallet.getAddressPreFixByte()) { + return Optional.empty(); + } System.arraycopy(plaintext, 0, amountArray, 0, 32); System.arraycopy(plaintext, 33, decryptedAddress, 0, 20); BigInteger decryptedAmount = ByteUtil.bytesToBigInteger(amountArray); @@ -4091,15 +4108,24 @@ public DecryptNotesTRC20 scanShieldedTRC20NotesByOvk(long startNum, long endNum, if (!Objects.isNull(logList)) { Optional noteTx; int index = 0; + byte[] pendingNf = null; for (TransactionInfo.Log log : logList) { int logType = getShieldedTRC20LogType(log, shieldedTRC20ContractAddress); - if (logType > 0) { + if (logType == 5) { + byte[] logData = log.getData().toByteArray(); + if (logData.length >= 32) { + pendingNf = ByteArray.subArray(logData, 0, 32); + } + } else if (logType > 0) { noteBuilder = DecryptNotesTRC20.NoteTx.newBuilder(); noteBuilder.setTxid(ByteString.copyFrom(txid)); noteBuilder.setIndex(index); index += 1; - noteTx = getNoteTxFromLogListByOvk(noteBuilder, log, ovk, logType); + noteTx = getNoteTxFromLogListByOvk(noteBuilder, log, ovk, logType, pendingNf); noteTx.ifPresent(builder::addNoteTxs); + if (logType == 4) { + pendingNf = null; + } } } } @@ -4283,12 +4309,45 @@ public BytesMessage getTriggerInputForShieldedTRC20Contract( parameterType); if (parametersBuilder.getShieldedTRC20ParametersType() == ShieldedTRC20ParametersType.BURN) { byte[] burnCiper = ByteArray.fromHexString(shieldedTRC20Parameters.getTriggerContractInput()); - if (!ArrayUtils.isEmpty(burnCiper) && burnCiper.length == 80) { - parametersBuilder.setBurnCiphertext(burnCiper); - } else { + if (ArrayUtils.isEmpty(burnCiper) + || burnCiper.length != NoteEncryption.Encryption.BURN_CIPHER_RECORD_SIZE) { + if (!ArrayUtils.isEmpty(burnCiper) && burnCiper.length == 80) { + throw new ZksnarkException( + "legacy 80-byte burn cipher is deprecated and rejected; expected " + + NoteEncryption.Encryption.BURN_CIPHER_RECORD_SIZE + "-byte burn record"); + } throw new ZksnarkException( "invalid shielded TRC-20 contract parameters for burn trigger input"); } + // v2-only: length alone would accept a legacy all-zero suffix and bypass + // the nf-bound nonce. Require reserved==v2 marker and nonce==derive(nf). + byte[] reserved = Arrays.copyOfRange(burnCiper, + NoteEncryption.Encryption.BURN_RESERVED_OFFSET, + NoteEncryption.Encryption.BURN_RESERVED_OFFSET + + NoteEncryption.Encryption.BURN_RESERVED_LEN); + if (!Arrays.equals(reserved, NoteEncryption.Encryption.getBurnRecordV2Marker())) { + throw new ZksnarkException( + "burn trigger input must be v2 (reserved=0x00000001); legacy/unknown markers rejected"); + } + if (shieldedTRC20Parameters.getSpendDescriptionList().size() != 1) { + throw new ZksnarkException( + "burn trigger input requires exactly one spendDescription for nf-bound nonce"); + } + byte[] nf = shieldedTRC20Parameters.getSpendDescription(0).getNullifier().toByteArray(); + if (nf.length != 32) { + throw new ZksnarkException( + "burn trigger input requires 32-byte spendDescription.nullifier"); + } + byte[] nonceFromInput = Arrays.copyOfRange(burnCiper, + NoteEncryption.Encryption.BURN_NONCE_OFFSET, + NoteEncryption.Encryption.BURN_NONCE_OFFSET + + NoteEncryption.Encryption.BURN_NONCE_LEN); + byte[] expectedNonce = NoteEncryption.Encryption.deriveNonceFromNf(nf); + if (!Arrays.equals(nonceFromInput, expectedNonce)) { + throw new ZksnarkException( + "burn trigger input nonce does not match nf-bound nonce"); + } + parametersBuilder.setBurnCiphertext(burnCiper); } String input = parametersBuilder .getTriggerContractInput(shieldedTRC20Parameters, spendAuthoritySignature, value, false, diff --git a/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java b/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java index 4b980c7b7c9..ba178f98b02 100644 --- a/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java +++ b/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java @@ -30,6 +30,7 @@ import org.tron.core.zen.address.PaymentAddress; import org.tron.core.zen.note.Note; import org.tron.core.zen.note.NoteEncryption; +import org.tron.core.zen.note.NoteEncryption.Encryption; import org.tron.core.zen.note.OutgoingPlaintext; import org.tron.protos.contract.ShieldContract; import org.tron.protos.contract.ShieldContract.ReceiveDescription; @@ -61,7 +62,9 @@ public class ShieldedTRC20ParametersBuilder { @Setter private BigInteger transparentToAmount; @Setter - private byte[] burnCiphertext = new byte[80]; + private byte[] ovk; + @Setter + private byte[] burnCiphertext = new byte[Encryption.BURN_CIPHER_RECORD_SIZE]; public ShieldedTRC20ParametersBuilder() { @@ -207,6 +210,9 @@ private ReceiveDescriptionCapsule generateOutputProof(ReceiveDescriptionInfo out private void createSpendAuth(byte[] dataToBeSigned) throws ZksnarkException { for (int i = 0; i < spends.size(); i++) { + if (spends.get(i).expsk == null) { + throw new ZksnarkException("missing expanded spending key for spend authorization"); + } byte[] result = new byte[64]; JLibrustzcash.librustzcashSaplingSpendSig( new LibrustzcashParam.SpendSigParams(spends.get(i).expsk.getAsk(), @@ -292,6 +298,25 @@ public ShieldedTRC20Parameters build(boolean withAsk) throws ZksnarkException { SpendDescriptionInfo spend = spends.get(0); spendDescription = generateSpendProof(spend, ctx).getInstance(); builder.addSpendDescription(spendDescription); + + if (ovk == null && spend.expsk != null) { + ovk = spend.expsk.getOvk(); + } + if (ovk == null) { + throw new ZksnarkException("missing ovk for burn encryption"); + } + byte[] nf = spendDescription.getNullifier().toByteArray(); + byte[] transparentToAddressTvm = normalizeTransparentToAddress(transparentToAddress); + byte[] addr21 = new byte[21]; + addr21[0] = Wallet.getAddressPreFixByte(); + System.arraycopy(transparentToAddressTvm, 0, addr21, 1, 20); + Optional cipherOpt = Encryption.encryptBurnMessageByOvk( + ovk, transparentToAmount, addr21, nf); + if (!cipherOpt.isPresent()) { + throw new ZksnarkException("encrypt burn message failed"); + } + burnCiphertext = cipherOpt.get(); + mergedBytes = ByteUtil.merge(shieldedTRC20Address, encodeSpendDescriptionWithoutSpendAuthSig(spendDescription)); if (receives.size() == 1) { @@ -302,7 +327,7 @@ public ShieldedTRC20Parameters build(boolean withAsk) throws ZksnarkException { encodeCencCout(receiveDescription)); } mergedBytes = ByteUtil - .merge(mergedBytes, transparentToAddress, ByteArray.fromLong(valueBalance)); + .merge(mergedBytes, transparentToAddressTvm, ByteArray.fromLong(valueBalance)); value = transparentToAmount; builder.setParameterType("burn"); break; @@ -476,12 +501,10 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, throw new IllegalArgumentException("the value must be positive"); } - if (ArrayUtils.isEmpty(transparentToAddress)) { - throw new IllegalArgumentException("the transparent payTo address is null"); - } + byte[] transparentToAddressTvm = normalizeTransparentToAddress(transparentToAddress); payTo[11] = Wallet.getAddressPreFixByte(); - System.arraycopy(transparentToAddress, 0, payTo, 12, 20); + System.arraycopy(transparentToAddressTvm, 0, payTo, 12, 20); ShieldContract.SpendDescription spendDesc = burnParams.getSpendDescription(0); byte[] spendAuthSign; @@ -492,7 +515,6 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, } byte[] mergedBytes; - byte[] zeros = new byte[16]; mergedBytes = ByteUtil.merge( spendDesc.getNullifier().toByteArray(), spendDesc.getAnchor().toByteArray(), @@ -503,8 +525,7 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, ByteUtil.bigIntegerToBytes(value, 32), burnParams.getBindingSignature().toByteArray(), payTo, - burnCiphertext, - zeros + burnCiphertext ); byte[] outputOffsetBytes; // 32 @@ -524,7 +545,7 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, coffsetBytes = ByteUtil.longTo32Bytes(mergedBytes.length + 32 * 3 + 32L * 9); countBytes = ByteUtil.longTo32Bytes(1L); ReceiveDescription recvDesc = burnParams.getReceiveDescription(0); - zeros = new byte[12]; + byte[] zeros = new byte[12]; mergedBytes = ByteUtil .merge(mergedBytes, outputOffsetBytes, @@ -542,6 +563,18 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, return Hex.toHexString(mergedBytes); } + private byte[] normalizeTransparentToAddress(byte[] transparentToAddress) { + if (transparentToAddress != null && transparentToAddress.length == 20) { + return transparentToAddress; + } + if (transparentToAddress != null && transparentToAddress.length == 21) { + byte[] transparentToAddressTvm = new byte[20]; + System.arraycopy(transparentToAddress, 1, transparentToAddressTvm, 0, 20); + return transparentToAddressTvm; + } + throw new IllegalArgumentException("invalid transparentToAddress for burn encryption"); + } + public void addSpend( ExpandedSpendingKey expsk, Note note, diff --git a/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java b/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java index 7d9de4ff596..49ea4827e28 100644 --- a/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java +++ b/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java @@ -8,10 +8,13 @@ import static org.tron.core.zen.note.NoteEncryption.Encryption.NOTEENCRYPTION_CIPHER_KEYSIZE; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Optional; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; +import org.tron.common.crypto.Hash; import org.tron.common.utils.ByteUtil; import org.tron.common.zksnark.JLibrustzcash; import org.tron.common.zksnark.JLibsodium; @@ -111,6 +114,19 @@ public OutCiphertext encryptToOurselves( public static class Encryption { public static final int NOTEENCRYPTION_CIPHER_KEYSIZE = 32; + public static final int BURN_CIPHER_LEN = 80; + public static final int BURN_NONCE_LEN = 12; + public static final int BURN_RESERVED_LEN = 4; + public static final int BURN_CIPHER_RECORD_SIZE = 96; + public static final int BURN_NONCE_OFFSET = BURN_CIPHER_LEN; + public static final int BURN_RESERVED_OFFSET = BURN_NONCE_OFFSET + BURN_NONCE_LEN; + private static final byte[] BURN_RECORD_V2_MARKER = new byte[]{0, 0, 0, 1}; + private static final byte[] BURN_NONCE_DOMAIN = + "Ztron_BurnNonce".getBytes(StandardCharsets.UTF_8); + + public static byte[] getBurnRecordV2Marker() { + return BURN_RECORD_V2_MARKER.clone(); + } /** * generate ock by ovk, cv, cm, epk @@ -246,47 +262,115 @@ public static Optional attemptOutDecryption( } /** - * encrypt the message by ovk used for scanning + * encrypt burn message with nf-derived nonce, returns a 96B record: + * cipher(80) + nonce(12) + reserved/version(4). */ public static Optional encryptBurnMessageByOvk(byte[] ovk, BigInteger toAmount, - byte[] transparentToAddress) + byte[] transparentToAddress, byte[] nf) throws ZksnarkException { + if (ovk == null || ovk.length != NOTEENCRYPTION_CIPHER_KEYSIZE) { + throw new ZksnarkException("invalid ovk length"); + } + if (transparentToAddress == null || transparentToAddress.length != 21) { + throw new ZksnarkException("invalid transparentToAddress length"); + } + if (nf == null || nf.length != 32) { + throw new ZksnarkException("invalid nullifier length"); + } byte[] plaintext = new byte[64]; byte[] amountArray = ByteUtil.bigIntegerToBytes(toAmount, 32); - byte[] cipherNonce = new byte[12]; - byte[] cipher = new byte[80]; + byte[] nonce = deriveNonceFromNf(nf); + byte[] cipher = new byte[BURN_CIPHER_LEN]; System.arraycopy(amountArray, 0, plaintext, 0, 32); - System.arraycopy(transparentToAddress, 0, plaintext, 32, - 21); + System.arraycopy(transparentToAddress, 0, plaintext, 32, 21); if (JLibsodium.cryptoAeadChacha20Poly1305IetfEncrypt(new Chacha20Poly1305IetfEncryptParams( cipher, null, plaintext, - 64, null, 0, null, cipherNonce, ovk)) != 0) { + 64, null, 0, null, nonce, ovk)) != 0) { return Optional.empty(); } - return Optional.of(cipher); + byte[] record = new byte[BURN_CIPHER_RECORD_SIZE]; + System.arraycopy(cipher, 0, record, 0, BURN_CIPHER_LEN); + System.arraycopy(nonce, 0, record, BURN_NONCE_OFFSET, BURN_NONCE_LEN); + System.arraycopy(BURN_RECORD_V2_MARKER, 0, record, BURN_RESERVED_OFFSET, BURN_RESERVED_LEN); + return Optional.of(record); } /** - * decrypt the message by ovk used for scanning + * Derive a 12-byte ChaCha20-Poly1305 nonce from the spend nullifier. + * The nullifier is prefixed with a fixed domain tag before hashing so the + * derivation cannot collide with any other SHA3-of-nf usage in the codebase. */ - public static Optional decryptBurnMessageByOvk(byte[] ovk, byte[] ciphertext) - throws ZksnarkException { + public static byte[] deriveNonceFromNf(byte[] nf) { + byte[] tagged = new byte[BURN_NONCE_DOMAIN.length + nf.length]; + System.arraycopy(BURN_NONCE_DOMAIN, 0, tagged, 0, BURN_NONCE_DOMAIN.length); + System.arraycopy(nf, 0, tagged, BURN_NONCE_DOMAIN.length, nf.length); + byte[] hash = Hash.sha3(tagged); + byte[] nonce = new byte[BURN_NONCE_LEN]; + System.arraycopy(hash, 0, nonce, 0, BURN_NONCE_LEN); + return nonce; + } + + /** + * decrypt burn message. The trailing 4-byte reserved field is treated as an explicit + * record-version marker: + * - reserved = 0x00000000 and nonce = 0x000000000000000000000000 -> legacy v1 path. + * - reserved = 0x00000001 -> v2 path, requiring nf-bound nonce verification. + * - any other reserved value -> reject. + */ + public static Optional decryptBurnMessageByOvk(byte[] ovk, byte[] ciphertext, + byte[] nonceFromLog, byte[] reservedFromLog, byte[] nf) throws ZksnarkException { + if (ovk == null || ovk.length != NOTEENCRYPTION_CIPHER_KEYSIZE) { + throw new ZksnarkException("invalid ovk length"); + } + if (ciphertext == null || ciphertext.length != BURN_CIPHER_LEN + || nonceFromLog == null || nonceFromLog.length != BURN_NONCE_LEN + || reservedFromLog == null || reservedFromLog.length != BURN_RESERVED_LEN) { + return Optional.empty(); + } + + byte[] effectiveNonce; + if (isAllZero(reservedFromLog)) { + if (!isAllZero(nonceFromLog)) { + return Optional.empty(); + } + effectiveNonce = nonceFromLog; + } else if (Arrays.equals(reservedFromLog, BURN_RECORD_V2_MARKER)) { + if (nf == null || nf.length != 32) { + return Optional.empty(); + } + byte[] derived = deriveNonceFromNf(nf); + if (!Arrays.equals(nonceFromLog, derived)) { + return Optional.empty(); + } + effectiveNonce = nonceFromLog; + } else { + return Optional.empty(); + } + byte[] outPlaintext = new byte[64]; - byte[] cipherNonce = new byte[12]; if (JLibsodium.cryptoAeadChacha20poly1305IetfDecrypt(new Chacha20poly1305IetfDecryptParams( outPlaintext, null, null, - ciphertext, 80, + ciphertext, BURN_CIPHER_LEN, null, 0, - cipherNonce, ovk)) != 0) { + effectiveNonce, ovk)) != 0) { return Optional.empty(); } return Optional.of(outPlaintext); } + private static boolean isAllZero(byte[] data) { + for (byte b : data) { + if (b != 0) { + return false; + } + } + return true; + } + public static class EncCiphertext { @Getter diff --git a/framework/src/test/java/org/tron/common/runtime/vm/PrecompiledContractsVerifyProofTest.java b/framework/src/test/java/org/tron/common/runtime/vm/PrecompiledContractsVerifyProofTest.java index 27e7891e6d8..9928d163a24 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/PrecompiledContractsVerifyProofTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/PrecompiledContractsVerifyProofTest.java @@ -1125,6 +1125,124 @@ public void verifyBurnWrongDataLength() throws ZksnarkException { Assert.assertEquals(0, result[31]); } + @Test + public void buildBurnRejectsInvalidTransparentToAddress() throws ZksnarkException { + long value = 100L; + SpendingKey senderSk = SpendingKey.random(); + ExpandedSpendingKey senderExpsk = senderSk.expandedSpendingKey(); + FullViewingKey senderFvk = senderSk.fullViewingKey(); + IncomingViewingKey senderIvk = senderFvk.inViewingKey(); + byte[] rcm = new byte[32]; + JLibrustzcash.librustzcashSaplingGenerateR(rcm); + PaymentAddress senderPaymentAddress = senderIvk.address(DiversifierT.random()).orElse(null); + assertNotNull(senderPaymentAddress); + + ShieldedTRC20ParametersBuilder builder = new ShieldedTRC20ParametersBuilder(); + builder.setShieldedTRC20ParametersType(ShieldedTRC20ParametersType.BURN); + builder.setShieldedTRC20Address(SHIELDED_CONTRACT_ADDRESS); + builder.setTransparentToAmount(BigInteger.valueOf(value)); + builder.setTransparentToAddress(new byte[19]); + + Note senderNote = new Note(senderPaymentAddress.getD(), senderPaymentAddress.getPkD(), + value, rcm, new byte[512]); + byte[][] cm = new byte[1][32]; + System.arraycopy(senderNote.cm(), 0, cm[0], 0, 32); + IncrementalMerkleTreeContainer tree = new IncrementalMerkleTreeContainer( + new IncrementalMerkleTreeCapsule()); + IncrementalMerkleVoucherContainer voucher = addSimpleMerkleVoucherContainer(tree, cm); + byte[] path = decodePath(voucher.path().encode()); + byte[] anchor = voucher.root().getContent().toByteArray(); + long position = voucher.position(); + builder.addSpend(senderExpsk, senderNote, anchor, path, position); + + try { + builder.build(true); + Assert.fail("expected ZksnarkException for invalid transparentToAddress"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage().contains("invalid transparentToAddress")); + } + } + + @Test + public void buildBurnRejectsMissingOvk() throws ZksnarkException { + long value = 100L; + SpendingKey senderSk = SpendingKey.random(); + ExpandedSpendingKey senderExpsk = senderSk.expandedSpendingKey(); + FullViewingKey senderFvk = senderSk.fullViewingKey(); + IncomingViewingKey senderIvk = senderFvk.inViewingKey(); + byte[] rcm = new byte[32]; + JLibrustzcash.librustzcashSaplingGenerateR(rcm); + PaymentAddress senderPaymentAddress = senderIvk.address(DiversifierT.random()).orElse(null); + assertNotNull(senderPaymentAddress); + + ShieldedTRC20ParametersBuilder builder = new ShieldedTRC20ParametersBuilder(); + builder.setShieldedTRC20ParametersType(ShieldedTRC20ParametersType.BURN); + builder.setShieldedTRC20Address(SHIELDED_CONTRACT_ADDRESS); + builder.setTransparentToAmount(BigInteger.valueOf(value)); + builder.setTransparentToAddress(PUBLIC_TO_ADDRESS); + + Note senderNote = new Note(senderPaymentAddress.getD(), senderPaymentAddress.getPkD(), + value, rcm, new byte[512]); + byte[][] cm = new byte[1][32]; + System.arraycopy(senderNote.cm(), 0, cm[0], 0, 32); + IncrementalMerkleTreeContainer tree = new IncrementalMerkleTreeContainer( + new IncrementalMerkleTreeCapsule()); + IncrementalMerkleVoucherContainer voucher = addSimpleMerkleVoucherContainer(tree, cm); + byte[] path = decodePath(voucher.path().encode()); + byte[] anchor = voucher.root().getContent().toByteArray(); + long position = voucher.position(); + builder.addSpend(senderFvk.getAk(), senderExpsk.getNsk(), senderNote, + Note.generateR(), anchor, path, position); + + try { + builder.build(false); + Assert.fail("expected ZksnarkException for missing ovk"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage().contains("missing ovk for burn encryption")); + } + } + + @Test + public void buildBurnRejectsMissingExpandedSpendingKeyForWithAsk() throws ZksnarkException { + long value = 100L; + SpendingKey senderSk = SpendingKey.random(); + ExpandedSpendingKey senderExpsk = senderSk.expandedSpendingKey(); + FullViewingKey senderFvk = senderSk.fullViewingKey(); + IncomingViewingKey senderIvk = senderFvk.inViewingKey(); + byte[] rcm = new byte[32]; + JLibrustzcash.librustzcashSaplingGenerateR(rcm); + PaymentAddress senderPaymentAddress = senderIvk.address(DiversifierT.random()).orElse(null); + assertNotNull(senderPaymentAddress); + + ShieldedTRC20ParametersBuilder builder = new ShieldedTRC20ParametersBuilder(); + builder.setShieldedTRC20ParametersType(ShieldedTRC20ParametersType.BURN); + builder.setShieldedTRC20Address(SHIELDED_CONTRACT_ADDRESS); + builder.setTransparentToAmount(BigInteger.valueOf(value)); + builder.setTransparentToAddress(PUBLIC_TO_ADDRESS); + builder.setOvk(senderFvk.getOvk()); + + Note senderNote = new Note(senderPaymentAddress.getD(), senderPaymentAddress.getPkD(), + value, rcm, new byte[512]); + byte[][] cm = new byte[1][32]; + System.arraycopy(senderNote.cm(), 0, cm[0], 0, 32); + IncrementalMerkleTreeContainer tree = new IncrementalMerkleTreeContainer( + new IncrementalMerkleTreeCapsule()); + IncrementalMerkleVoucherContainer voucher = addSimpleMerkleVoucherContainer(tree, cm); + byte[] path = decodePath(voucher.path().encode()); + byte[] anchor = voucher.root().getContent().toByteArray(); + long position = voucher.position(); + builder.addSpend(senderFvk.getAk(), senderExpsk.getNsk(), senderNote, + Note.generateR(), anchor, path, position); + + try { + builder.build(true); + Assert.fail("expected ZksnarkException for missing expanded spending key"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage().contains( + "missing expanded spending key for spend authorization")); + } + } + @Test public void verifyMintWrongLeafcount() throws ZksnarkException { long value = 100L; diff --git a/framework/src/test/java/org/tron/core/WalletMockTest.java b/framework/src/test/java/org/tron/core/WalletMockTest.java index 3e0c1a4461d..c3805517f6e 100644 --- a/framework/src/test/java/org/tron/core/WalletMockTest.java +++ b/framework/src/test/java/org/tron/core/WalletMockTest.java @@ -7,10 +7,12 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import com.google.common.cache.Cache; @@ -24,6 +26,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeUnit; import org.junit.After; @@ -39,6 +42,7 @@ import org.tron.common.utils.ByteUtil; import org.tron.common.utils.Sha256Hash; import org.tron.common.utils.client.WalletClient; +import org.tron.common.zksnark.JLibrustzcash; import org.tron.core.capsule.AccountCapsule; import org.tron.core.capsule.BlockCapsule; import org.tron.core.capsule.ContractCapsule; @@ -76,6 +80,7 @@ import org.tron.core.zen.address.ExpandedSpendingKey; import org.tron.core.zen.address.KeyIo; import org.tron.core.zen.address.PaymentAddress; +import org.tron.core.zen.note.Note; import org.tron.protos.Protocol; import org.tron.protos.contract.BalanceContract; import org.tron.protos.contract.ShieldContract; @@ -1142,9 +1147,10 @@ public void testGetShieldedTRC20LogTypeReturnsCorrectInt() throws Exception { "MintNewLeaf(uint256,bytes32,bytes32,bytes32,bytes32[21])", "TransferNewLeaf(uint256,bytes32,bytes32,bytes32,bytes32[21])", "BurnNewLeaf(uint256,bytes32,bytes32,bytes32,bytes32[21])", - "TokenBurn(address,uint256,bytes32[3])" + "TokenBurn(address,uint256,bytes32[3])", + "NoteSpent(bytes32)" }; - int[] expectedTypes = {1, 2, 3, 4}; + int[] expectedTypes = {1, 2, 3, 4, 5}; for (int i = 0; i < eventSignatures.length; i++) { byte[] topicHash = Hash.sha3(ByteArray.fromString(eventSignatures[i])); @@ -1158,6 +1164,70 @@ public void testGetShieldedTRC20LogTypeReturnsCorrectInt() throws Exception { } } + @Test + public void scanShieldedTRC20NotesByIvkSkipsNoteSpentIndex() throws Exception { + final String SHIELDED_CONTRACT_ADDRESS_STR = "TGAmX5AqVUoXCf8MoHxbuhQPmhGfWTnEgA"; + byte[] contractAddress = WalletClient.decodeFromBase58Check(SHIELDED_CONTRACT_ADDRESS_STR); + byte[] addressWithoutPrefix = new byte[20]; + System.arraycopy(contractAddress, 1, addressWithoutPrefix, 0, 20); + + byte[] noteSpentTopic = Hash.sha3(ByteArray.fromString("NoteSpent(bytes32)")); + Protocol.TransactionInfo.Log noteSpentLog = Protocol.TransactionInfo.Log.newBuilder() + .setAddress(ByteString.copyFrom(addressWithoutPrefix)) + .addTopics(ByteString.copyFrom(noteSpentTopic)) + .setData(ByteString.copyFrom(new byte[32])) + .build(); + + byte[] transferTopic = Hash.sha3(ByteArray.fromString( + "TransferNewLeaf(uint256,bytes32,bytes32,bytes32,bytes32[21])")); + // getNoteTxFromLogListByIvk slices bytes 0..708; only `pos` (bytes 0..32) is read here. + byte[] transferData = new byte[708]; + Protocol.TransactionInfo.Log transferLog = Protocol.TransactionInfo.Log.newBuilder() + .setAddress(ByteString.copyFrom(addressWithoutPrefix)) + .addTopics(ByteString.copyFrom(transferTopic)) + .setData(ByteString.copyFrom(transferData)) + .build(); + + Protocol.TransactionInfo info = Protocol.TransactionInfo.newBuilder() + .addLog(noteSpentLog) + .addLog(transferLog) + .build(); + + Protocol.Block block = Protocol.Block.newBuilder() + .addTransactions(Protocol.Transaction.newBuilder().build()) + .build(); + GrpcAPI.BlockList blockList = GrpcAPI.BlockList.newBuilder().addBlock(block).build(); + + Wallet wallet = spy(new Wallet()); + doReturn(blockList).when(wallet).getBlocksByLimitNext(anyLong(), anyLong()); + doReturn(info).when(wallet).getTransactionInfoById(any()); + + // Bypass the real ZK crypto: return a valid note and a deterministic payment address + // so the scanner reaches the index-assignment branch. + Note fakeNote = new Note(new DiversifierT(), new byte[32], 100L, + new byte[32], new byte[512]); + boolean prevAllow = CommonParameter.getInstance().isAllowShieldedTransactionApi(); + CommonParameter.getInstance().setAllowShieldedTransactionApi(true); + try (MockedStatic noteMock = mockStatic(Note.class); + MockedStatic rustMock = mockStatic(JLibrustzcash.class); + MockedStatic keyIoMock = mockStatic(KeyIo.class)) { + noteMock.when(() -> Note.decrypt(any(byte[].class), any(byte[].class), + any(byte[].class), any(byte[].class))).thenReturn(Optional.of(fakeNote)); + rustMock.when(() -> JLibrustzcash.librustzcashIvkToPkd(any())).thenReturn(true); + keyIoMock.when(() -> KeyIo.encodePaymentAddress(any())).thenReturn("zaddrFake"); + + byte[] ivk = new byte[32]; + GrpcAPI.DecryptNotesTRC20 result = wallet.scanShieldedTRC20NotesByIvk( + 0L, 1L, contractAddress, ivk, new byte[0], new byte[0]); + + assertEquals(1, result.getNoteTxsCount()); + assertEquals("TransferNewLeaf must get index 0; NoteSpent must not advance the counter", + 0L, result.getNoteTxs(0).getIndex()); + } finally { + CommonParameter.getInstance().setAllowShieldedTransactionApi(prevAllow); + } + } + @Test public void testBuildShieldedTRC20InputWithAK() throws ZksnarkException { Wallet wallet = new Wallet(); diff --git a/framework/src/test/java/org/tron/core/zen/note/BurnCipherTest.java b/framework/src/test/java/org/tron/core/zen/note/BurnCipherTest.java new file mode 100644 index 00000000000..e3cea47cfe3 --- /dev/null +++ b/framework/src/test/java/org/tron/core/zen/note/BurnCipherTest.java @@ -0,0 +1,248 @@ +package org.tron.core.zen.note; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Optional; +import org.junit.Assert; +import org.junit.Test; +import org.tron.common.utils.ByteUtil; +import org.tron.core.exception.ZksnarkException; +import org.tron.core.zen.note.NoteEncryption.Encryption; + +public class BurnCipherTest { + + private static final byte[] OVK = buildTestBytes(32, 1); + private static final byte[] NF = buildTestBytes(32, 7); + private static final byte[] ADDR_21 = buildAddr21((byte) 0x41); + + private static byte[] buildTestBytes(int len, int seed) { + byte[] data = new byte[len]; + for (int i = 0; i < len; i++) { + data[i] = (byte) (i * 3 + seed); + } + return data; + } + + private static byte[] buildAddr21(byte prefix) { + byte[] addr = new byte[21]; + addr[0] = prefix; + for (int i = 1; i < 21; i++) { + addr[i] = (byte) (i * 2); + } + return addr; + } + + private static byte[] extractCipher(byte[] record) { + return Arrays.copyOf(record, Encryption.BURN_CIPHER_LEN); + } + + private static byte[] extractNonce(byte[] record) { + return Arrays.copyOfRange(record, + Encryption.BURN_NONCE_OFFSET, + Encryption.BURN_NONCE_OFFSET + Encryption.BURN_NONCE_LEN); + } + + private static byte[] extractReserved(byte[] record) { + return Arrays.copyOfRange(record, + Encryption.BURN_RESERVED_OFFSET, + Encryption.BURN_RESERVED_OFFSET + Encryption.BURN_RESERVED_LEN); + } + + // ---------- constants ---------- + + @Test + public void testBurnCipherSize() { + Assert.assertEquals(80, Encryption.BURN_CIPHER_LEN); + Assert.assertEquals(12, Encryption.BURN_NONCE_LEN); + Assert.assertEquals(4, Encryption.BURN_RESERVED_LEN); + Assert.assertEquals(96, Encryption.BURN_CIPHER_RECORD_SIZE); + } + + // ---------- encrypt ---------- + + @Test + public void testEncryptProduces96ByteRecord() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + Optional recordOpt = Encryption.encryptBurnMessageByOvk( + OVK, amount, ADDR_21, NF); + Assert.assertTrue(recordOpt.isPresent()); + Assert.assertEquals(Encryption.BURN_CIPHER_RECORD_SIZE, recordOpt.get().length); + } + + @Test + public void testRecordReservedBytesCarryV2Marker() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + Assert.assertArrayEquals(new byte[]{0, 0, 0, 1}, extractReserved(record)); + } + + @Test + public void testNonceEmbeddedInRecord() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] nonce = extractNonce(record); + boolean allZero = true; + for (byte b : nonce) { + if (b != 0) { + allZero = false; + break; + } + } + Assert.assertFalse(allZero); + } + + @Test + public void testNonceDeterminism() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + byte[] record1 = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] record2 = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + Assert.assertArrayEquals(record1, record2); + } + + @Test + public void testDifferentNfProducesDifferentRecord() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + byte[] nf2 = new byte[32]; + nf2[0] = (byte) 0xFF; + + byte[] record1 = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] record2 = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, nf2).get(); + Assert.assertFalse(Arrays.equals(record1, record2)); + } + + // ---------- encrypt input validation ---------- + + @Test(expected = ZksnarkException.class) + public void testEncryptRejectsNullNf() throws ZksnarkException { + Encryption.encryptBurnMessageByOvk(OVK, BigInteger.ONE, ADDR_21, null); + } + + @Test(expected = ZksnarkException.class) + public void testEncryptRejectsShortOvk() throws ZksnarkException { + Encryption.encryptBurnMessageByOvk(new byte[16], BigInteger.ONE, ADDR_21, NF); + } + + @Test(expected = ZksnarkException.class) + public void testEncryptRejectsBadAddrLength() throws ZksnarkException { + Encryption.encryptBurnMessageByOvk(OVK, BigInteger.ONE, new byte[20], NF); + } + + // ---------- decrypt round-trip ---------- + + @Test + public void testEncryptDecryptRoundTrip() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + byte[] nonce = extractNonce(record); + + Optional plainOpt = Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, extractReserved(record), NF); + Assert.assertTrue(plainOpt.isPresent()); + byte[] plaintext = plainOpt.get(); + + byte[] decryptedAmount = new byte[32]; + System.arraycopy(plaintext, 0, decryptedAmount, 0, 32); + Assert.assertEquals(amount, ByteUtil.bytesToBigInteger(decryptedAmount)); + + byte[] decryptedAddr = new byte[21]; + System.arraycopy(plaintext, 32, decryptedAddr, 0, 21); + Assert.assertArrayEquals(ADDR_21, decryptedAddr); + } + + @Test + public void testDecryptWithWrongNfFails() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(500000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + byte[] nonce = extractNonce(record); + + byte[] wrongNf = new byte[32]; + wrongNf[0] = (byte) 0xFF; + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, extractReserved(record), wrongNf); + Assert.assertFalse(result.isPresent()); + } + + @Test + public void testDecryptWithNullNfFailsForV2() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(500000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + byte[] nonce = extractNonce(record); + + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, extractReserved(record), null); + Assert.assertFalse(result.isPresent()); + } + + @Test + public void testDecryptWithWrongNfLengthFailsForV2() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(500000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + byte[] nonce = extractNonce(record); + byte[] reserved = extractReserved(record); + + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, reserved, new byte[31]).isPresent()); + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, reserved, new byte[33]).isPresent()); + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, reserved, new byte[0]).isPresent()); + } + + @Test + public void testDecryptWithTamperedNonceFails() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(500000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + + byte[] tamperedNonce = new byte[Encryption.BURN_NONCE_LEN]; + tamperedNonce[0] = (byte) 0xDE; + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, cipher, tamperedNonce, extractReserved(record), NF); + Assert.assertFalse(result.isPresent()); + } + + @Test + public void testDecryptWithUnknownReservedMarkerFails() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(500000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + byte[] nonce = extractNonce(record); + byte[] badReserved = new byte[]{0, 0, 0, 2}; + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, badReserved, NF); + Assert.assertFalse(result.isPresent()); + } + + // ---------- decrypt input validation ---------- + + @Test(expected = ZksnarkException.class) + public void testDecryptRejectsNullOvk() throws ZksnarkException { + Encryption.decryptBurnMessageByOvk(null, new byte[80], new byte[12], new byte[4], NF); + } + + @Test + public void testDecryptRejectsBadCipherLength() throws ZksnarkException { + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, new byte[64], new byte[12], new byte[4], NF); + Assert.assertFalse(result.isPresent()); + } + + @Test + public void testDecryptRejectsNullNonce() throws ZksnarkException { + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, new byte[80], null, new byte[4], NF); + Assert.assertFalse(result.isPresent()); + } + + @Test + public void testDecryptRejectsNullReserved() throws ZksnarkException { + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, new byte[80], new byte[12], null, NF); + Assert.assertFalse(result.isPresent()); + } +} diff --git a/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java b/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java index 3c3fb14b2b1..a8268d12afd 100644 --- a/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java +++ b/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java @@ -1,14 +1,20 @@ package org.tron.core.zksnark; import com.google.protobuf.ByteString; +import java.lang.reflect.Method; +import java.math.BigInteger; import java.util.Optional; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.tron.api.GrpcAPI; import org.tron.common.BaseTest; import org.tron.common.utils.ByteArray; +import org.tron.common.utils.ByteUtil; +import org.tron.common.zksnark.JLibsodium; +import org.tron.common.zksnark.JLibsodiumParam.Chacha20Poly1305IetfEncryptParams; import org.tron.core.Wallet; import org.tron.core.capsule.AssetIssueCapsule; import org.tron.core.config.args.Args; @@ -17,7 +23,9 @@ import org.tron.core.zen.note.NoteEncryption.Encryption; import org.tron.core.zen.note.NoteEncryption.Encryption.OutCiphertext; import org.tron.core.zen.note.OutgoingPlaintext; +import org.tron.protos.Protocol.TransactionInfo; import org.tron.protos.contract.AssetIssueContractOuterClass.AssetIssueContract; +import org.tron.protos.contract.ShieldContract; @Slf4j public class NoteEncDecryTest extends BaseTest { @@ -193,4 +201,311 @@ public void testDecryptEncWithEpk() throws ZksnarkException { Assert.assertArrayEquals(rcm, result2.getRcm()); Assert.assertEquals(4000, result2.getValue()); } + + @Test + public void testBurnMessageOvkLegacyZeroNonce() throws ZksnarkException { + byte[] ovk = new byte[]{ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}; + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + toAddress[20] = 0x42; + BigInteger amount = BigInteger.valueOf(99L); + + byte[] plaintext = new byte[64]; + byte[] amountArr = ByteUtil.bigIntegerToBytes(amount, 32); + System.arraycopy(amountArr, 0, plaintext, 0, 32); + System.arraycopy(toAddress, 0, plaintext, 32, 21); + byte[] zeroNonce = new byte[12]; + byte[] v1Cipher = new byte[Encryption.BURN_CIPHER_LEN]; + int rc = JLibsodium.cryptoAeadChacha20Poly1305IetfEncrypt( + new Chacha20Poly1305IetfEncryptParams( + v1Cipher, null, plaintext, 64, null, 0, null, zeroNonce, ovk)); + Assert.assertEquals(0, rc); + + Optional p1 = Encryption.decryptBurnMessageByOvk( + ovk, v1Cipher, new byte[12], new byte[4], null); + Assert.assertTrue(p1.isPresent()); + Assert.assertArrayEquals(plaintext, p1.get()); + + byte[] wrongNonce = new byte[12]; + wrongNonce[0] = 1; + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + ovk, v1Cipher, wrongNonce, new byte[4], null).isPresent()); + + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + ovk, v1Cipher, new byte[11], new byte[4], null).isPresent()); + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + ovk, v1Cipher, null, new byte[4], null).isPresent()); + } + + @Test + public void testGetTriggerInputBurnV2Accepted() throws Exception { + byte[] nf = new byte[32]; + for (int i = 0; i < nf.length; i++) { + nf[i] = (byte) (i + 1); + } + byte[] burnRecord = buildV2BurnRecord(nf); + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(burnRecord, nf); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, BigInteger.ONE); + GrpcAPI.BytesMessage out = wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.assertNotNull(out); + } + + @Test + public void testGetTriggerInputBurnLegacy96ByteRecordRejected() throws Exception { + byte[] allZeroRecord = new byte[Encryption.BURN_CIPHER_RECORD_SIZE]; + byte[] nf = new byte[32]; + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(allZeroRecord, nf); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, BigInteger.ONE); + try { + wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.fail("expected ZksnarkException for legacy 96-byte burn record"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("v2")); + } + } + + @Test + public void testGetTriggerInputBurnUnknownReservedRejected() throws Exception { + byte[] nf = new byte[32]; + nf[0] = 0x5A; + byte[] record = buildV2BurnRecord(nf); + // mutate reserved to an unknown marker (0x00000002). + record[Encryption.BURN_RESERVED_OFFSET + Encryption.BURN_RESERVED_LEN - 1] = 2; + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(record, nf); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, BigInteger.ONE); + try { + wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.fail("expected ZksnarkException for unknown reserved marker"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("v2")); + } + } + + @Test + public void testGetTriggerInputBurnNonceMismatchRejected() throws Exception { + byte[] nf = new byte[32]; + nf[0] = 0x11; + byte[] record = buildV2BurnRecord(nf); + // flip one nonce byte so it no longer matches deriveNonceFromNf(nf). + record[Encryption.BURN_NONCE_OFFSET] ^= (byte) 0xFF; + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(record, nf); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, BigInteger.ONE); + try { + wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.fail("expected ZksnarkException for mismatched nf-bound nonce"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("nonce")); + } + } + + @Test + public void testGetTriggerInputBurn80ByteCipherRejected() throws Exception { + byte[] legacyCipher = new byte[Encryption.BURN_CIPHER_LEN]; + byte[] nf = new byte[32]; + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(legacyCipher, nf); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, BigInteger.ONE); + try { + wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.fail("expected ZksnarkException for 80-byte burn cipher"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage().contains("deprecated")); + } + } + + private static byte[] buildV2BurnRecord(byte[] nf) { + byte[] record = new byte[Encryption.BURN_CIPHER_RECORD_SIZE]; + // cipher(0..80) left as zeros — getTriggerInputForShieldedTRC20Contract only + // checks reserved marker and nf-bound nonce, not cipher decryptability. + byte[] nonce = Encryption.deriveNonceFromNf(nf); + System.arraycopy(nonce, 0, record, Encryption.BURN_NONCE_OFFSET, Encryption.BURN_NONCE_LEN); + byte[] marker = Encryption.getBurnRecordV2Marker(); + System.arraycopy(marker, 0, record, Encryption.BURN_RESERVED_OFFSET, + Encryption.BURN_RESERVED_LEN); + return record; + } + + @Test + public void testGetNoteTxFromLogListByOvkBurnTooShort() throws Exception { + Wallet w = new Wallet(); + byte[] ovk = new byte[32]; + byte[] logData = new byte[64 + Encryption.BURN_CIPHER_RECORD_SIZE - 1]; + TransactionInfo.Log log = TransactionInfo.Log.newBuilder() + .setData(ByteString.copyFrom(logData)).build(); + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder builder = + GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(); + + Method m = Wallet.class.getDeclaredMethod("getNoteTxFromLogListByOvk", + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder.class, + TransactionInfo.Log.class, byte[].class, int.class, byte[].class); + m.setAccessible(true); + Object result = m.invoke(w, builder, log, ovk, 4, null); + Assert.assertFalse(((Optional) result).isPresent()); + } + + @Test + public void testGetNoteTxFromLogListByOvkBurnRoundTrip() throws Exception { + Wallet w = new Wallet(); + byte[] ovk = new byte[32]; + for (int i = 0; i < 32; i++) { + ovk[i] = (byte) (i + 1); + } + BigInteger amount = BigInteger.valueOf(1000L); + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + toAddress[20] = 0x42; + byte[] nf = new byte[32]; + nf[0] = (byte) 0xAB; + + Optional recordOpt = Encryption.encryptBurnMessageByOvk( + ovk, amount, toAddress, nf); + Assert.assertTrue(recordOpt.isPresent()); + byte[] record = recordOpt.get(); + + byte[] logData = new byte[64 + Encryption.BURN_CIPHER_RECORD_SIZE]; + System.arraycopy(toAddress, 1, logData, 12, 20); + byte[] valBytes = ByteUtil.bigIntegerToBytes(amount, 32); + System.arraycopy(valBytes, 0, logData, 32, 32); + System.arraycopy(record, 0, logData, 64, Encryption.BURN_CIPHER_RECORD_SIZE); + + TransactionInfo.Log log = TransactionInfo.Log.newBuilder() + .setData(ByteString.copyFrom(logData)).build(); + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder builder = + GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(); + + Method m = Wallet.class.getDeclaredMethod("getNoteTxFromLogListByOvk", + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder.class, + TransactionInfo.Log.class, byte[].class, int.class, byte[].class); + m.setAccessible(true); + Object result = m.invoke(w, builder, log, ovk, 4, nf); + Assert.assertTrue(((Optional) result).isPresent()); + } + + @Test + public void testGetNoteTxFromLogListByOvkBurnMissingNfRejected() throws Exception { + Wallet w = new Wallet(); + byte[] ovk = new byte[32]; + for (int i = 0; i < 32; i++) { + ovk[i] = (byte) (i + 1); + } + BigInteger amount = BigInteger.valueOf(1000L); + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + toAddress[20] = 0x42; + byte[] nf = new byte[32]; + nf[0] = (byte) 0xAB; + + Optional recordOpt = Encryption.encryptBurnMessageByOvk( + ovk, amount, toAddress, nf); + Assert.assertTrue(recordOpt.isPresent()); + byte[] record = recordOpt.get(); + + byte[] logData = new byte[64 + Encryption.BURN_CIPHER_RECORD_SIZE]; + System.arraycopy(toAddress, 1, logData, 12, 20); + byte[] valBytes = ByteUtil.bigIntegerToBytes(amount, 32); + System.arraycopy(valBytes, 0, logData, 32, 32); + System.arraycopy(record, 0, logData, 64, Encryption.BURN_CIPHER_RECORD_SIZE); + + TransactionInfo.Log log = TransactionInfo.Log.newBuilder() + .setData(ByteString.copyFrom(logData)).build(); + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder builder = + GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(); + + Method m = Wallet.class.getDeclaredMethod("getNoteTxFromLogListByOvk", + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder.class, + TransactionInfo.Log.class, byte[].class, int.class, byte[].class); + m.setAccessible(true); + Object result = m.invoke(w, builder, log, ovk, 4, null); + Assert.assertFalse(((Optional) result).isPresent()); + } + + @Test + public void testGetNoteTxFromLogListByOvkTwoBurnsCursorPairing() throws Exception { + Wallet w = new Wallet(); + byte[] ovk = new byte[32]; + for (int i = 0; i < 32; i++) { + ovk[i] = (byte) (i + 1); + } + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + toAddress[20] = 0x42; + + byte[] nf1 = new byte[32]; + nf1[0] = (byte) 0xAA; + byte[] nf2 = new byte[32]; + nf2[0] = (byte) 0xBB; + BigInteger amount1 = BigInteger.valueOf(1000L); + BigInteger amount2 = BigInteger.valueOf(2000L); + + TransactionInfo.Log log1 = buildBurnLog(ovk, amount1, toAddress, nf1); + TransactionInfo.Log log2 = buildBurnLog(ovk, amount2, toAddress, nf2); + + Method m = Wallet.class.getDeclaredMethod("getNoteTxFromLogListByOvk", + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder.class, + TransactionInfo.Log.class, byte[].class, int.class, byte[].class); + m.setAccessible(true); + + // correct cursor pairing: each log decrypted with its own nf + Optional r1 = (Optional) m.invoke( + w, GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(), log1, ovk, 4, nf1); + Optional r2 = (Optional) m.invoke( + w, GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(), log2, ovk, 4, nf2); + Assert.assertTrue("burn1 should decrypt with nf1", r1.isPresent()); + Assert.assertTrue("burn2 should decrypt with nf2", r2.isPresent()); + GrpcAPI.DecryptNotesTRC20.NoteTx tx1 = (GrpcAPI.DecryptNotesTRC20.NoteTx) r1.get(); + GrpcAPI.DecryptNotesTRC20.NoteTx tx2 = (GrpcAPI.DecryptNotesTRC20.NoteTx) r2.get(); + Assert.assertEquals(amount1.toString(10), tx1.getToAmount()); + Assert.assertEquals(amount2.toString(10), tx2.getToAmount()); + + // mis-paired cursor: nonce-from-log mismatches sha3(domain||nf), strict mode rejects + Optional bad1 = (Optional) m.invoke( + w, GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(), log1, ovk, 4, nf2); + Optional bad2 = (Optional) m.invoke( + w, GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(), log2, ovk, 4, nf1); + Assert.assertFalse("burn1 must not decrypt under nf2", bad1.isPresent()); + Assert.assertFalse("burn2 must not decrypt under nf1", bad2.isPresent()); + } + + private TransactionInfo.Log buildBurnLog(byte[] ovk, BigInteger amount, byte[] toAddress, + byte[] nf) throws ZksnarkException { + Optional recordOpt = Encryption.encryptBurnMessageByOvk(ovk, amount, toAddress, nf); + Assert.assertTrue(recordOpt.isPresent()); + byte[] record = recordOpt.get(); + byte[] logData = new byte[64 + Encryption.BURN_CIPHER_RECORD_SIZE]; + System.arraycopy(toAddress, 1, logData, 12, 20); + byte[] valBytes = ByteUtil.bigIntegerToBytes(amount, 32); + System.arraycopy(valBytes, 0, logData, 32, 32); + System.arraycopy(record, 0, logData, 64, Encryption.BURN_CIPHER_RECORD_SIZE); + return TransactionInfo.Log.newBuilder() + .setData(ByteString.copyFrom(logData)).build(); + } + + private GrpcAPI.ShieldedTRC20Parameters buildBurnTrc20Params(byte[] cipher, byte[] nf) { + ShieldContract.SpendDescription spend = ShieldContract.SpendDescription.newBuilder() + .setNullifier(ByteString.copyFrom(nf)) + .build(); + return GrpcAPI.ShieldedTRC20Parameters.newBuilder() + .setParameterType("burn") + .setTriggerContractInput(ByteArray.toHexString(cipher)) + .addSpendDescription(spend) + .build(); + } + + private GrpcAPI.ShieldedTRC20TriggerContractParameters buildBurnTriggerRequest( + GrpcAPI.ShieldedTRC20Parameters trc20Params, BigInteger value) { + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + return GrpcAPI.ShieldedTRC20TriggerContractParameters.newBuilder() + .setShieldedTRC20Parameters(trc20Params) + .addSpendAuthoritySignature(GrpcAPI.BytesMessage.getDefaultInstance()) + .setAmount(value.toString()) + .setTransparentToAddress(ByteString.copyFrom(toAddress)) + .build(); + } }