Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@
import org.tron.core.exception.ContractValidateException;
import org.tron.core.store.AccountStore;
import org.tron.core.store.DynamicPropertiesStore;
import org.tron.common.crypto.pqc.MLDSA44;
import org.tron.common.crypto.pqc.MLDSA65;
import org.tron.common.crypto.pqc.PqSignatureRegistry;
import org.tron.protos.Protocol.Key;
import org.tron.protos.Protocol.Permission;
import org.tron.protos.Protocol.Permission.PermissionType;
import org.tron.protos.Protocol.SignatureScheme;
import org.tron.protos.Protocol.Transaction.Contract.ContractType;
import org.tron.protos.Protocol.Transaction.Result.code;
import org.tron.protos.contract.AccountContract.AccountPermissionUpdateContract;
Expand Down Expand Up @@ -102,6 +106,23 @@ private boolean checkPermission(Permission permission) throws ContractValidateEx
throw new ContractValidateException(
"address should be distinct in permission " + permission.getType());
}
validatePermissionScheme(permission);

List<ByteString> publicKeyList = permission.getKeysList()
.stream()
.map(Key::getPublicKey)
.filter(pk -> !pk.isEmpty())
.distinct()
.collect(toList());
long nonEmptyPublicKeyCount = permission.getKeysList().stream()
.map(Key::getPublicKey)
.filter(pk -> !pk.isEmpty())
.count();
if (publicKeyList.size() != nonEmptyPublicKeyCount) {
throw new ContractValidateException(
"public_key should be distinct in permission " + permission.getType());
}

for (Key key : permission.getKeysList()) {
if (!DecodeUtil.addressValid(key.getAddress().toByteArray())) {
throw new ContractValidateException("key is not a validate address");
Expand Down Expand Up @@ -237,4 +258,57 @@ public ByteString getOwnerAddress() throws InvalidProtocolBufferException {
public long calcFee() {
return chainBaseManager.getDynamicPropertiesStore().getUpdateAccountPermissionFee();
}

private void validatePermissionScheme(Permission permission) throws ContractValidateException {
DynamicPropertiesStore dynamicStore = chainBaseManager.getDynamicPropertiesStore();
boolean mlDsaAllowed = dynamicStore.allowMlDsa();

SignatureScheme first = permission.getKeysList().get(0).getScheme();
for (Key key : permission.getKeysList()) {
SignatureScheme scheme = key.getScheme();
if (scheme != first) {
throw new ContractValidateException(
"all keys in a permission must use the same scheme");
}
if (scheme == SignatureScheme.UNKNOWN_SIG_SCHEME) {
if (!key.getPublicKey().isEmpty()) {
throw new ContractValidateException(
"public_key must be empty when scheme is UNKNOWN_SIG_SCHEME");
}
} else {
if (!mlDsaAllowed) {
throw new ContractValidateException(
"ML-DSA is not activated, scheme " + scheme + " is not allowed");
}
int expected = expectedPublicKeyLength(scheme);
if (expected < 0) {
throw new ContractValidateException(
"unsupported signature scheme: " + scheme);
}
if (key.getPublicKey().size() != expected) {
throw new ContractValidateException(
"public_key length for " + scheme + " must be " + expected + " bytes, got "
+ key.getPublicKey().size());
}
}
}

if (permission.getType() == PermissionType.Witness
&& first != SignatureScheme.UNKNOWN_SIG_SCHEME
&& !PqSignatureRegistry.contains(first)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
throw new ContractValidateException(
"Witness permission only supports legacy or registered PQ schemes, got " + first);
}
}

private static int expectedPublicKeyLength(SignatureScheme scheme) {
switch (scheme) {
case ML_DSA_44:
return MLDSA44.PUBLIC_KEY_LENGTH;
case ML_DSA_65:
return MLDSA65.PUBLIC_KEY_LENGTH;
default:
return -1;
}
}
}
14 changes: 13 additions & 1 deletion actuator/src/main/java/org/tron/core/utils/ProposalUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,17 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore,
}
break;
}
case ALLOW_ML_DSA: {
if (dynamicPropertiesStore.getAllowMlDsa() == 1) {
throw new ContractValidateException(
"[ALLOW_ML_DSA] has been valid, no need to propose again");
}
if (value != 1) {
throw new ContractValidateException(
"This value[ALLOW_ML_DSA] is only allowed to be 1");
}
break;
}
default:
break;
}
Expand Down Expand Up @@ -971,7 +982,8 @@ public enum ProposalType { // current value, value range
ALLOW_TVM_BLOB(89), // 0, 1
PROPOSAL_EXPIRE_TIME(92), // (0, 31536003000)
ALLOW_TVM_SELFDESTRUCT_RESTRICTION(94), // 0, 1
ALLOW_TVM_OSAKA(96); // 0, 1
ALLOW_TVM_OSAKA(96), // 0, 1
ALLOW_ML_DSA(97); // 0, 1

private long code;

Expand Down
89 changes: 89 additions & 0 deletions actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
import org.tron.common.crypto.Rsv;
import org.tron.common.crypto.SignUtils;
import org.tron.common.crypto.SignatureInterface;
import org.tron.common.crypto.pqc.MLDSA44;
import org.tron.common.crypto.pqc.MLDSA65;
import org.tron.common.crypto.zksnark.BN128;
import org.tron.common.crypto.zksnark.BN128Fp;
import org.tron.common.crypto.zksnark.BN128G1;
Expand Down Expand Up @@ -107,6 +109,9 @@ public class PrecompiledContracts {
private static final EthRipemd160 ethRipemd160 = new EthRipemd160();
private static final Blake2F blake2F = new Blake2F();

private static final VerifyMlDsa44 verifyMlDsa44 = new VerifyMlDsa44();
private static final VerifyMlDsa65 verifyMlDsa65 = new VerifyMlDsa65();

// FreezeV2 PrecompileContracts
private static final GetChainParameter getChainParameter = new GetChainParameter();
private static final AvailableUnfreezeV2Size availableUnfreezeV2Size = new AvailableUnfreezeV2Size();
Expand Down Expand Up @@ -200,6 +205,15 @@ public class PrecompiledContracts {
private static final DataWord blake2FAddr = new DataWord(
"0000000000000000000000000000000000000000000000000000000000020009");

// EIP-8051 0x12: ML-DSA-44 verify (FIPS-204, SHAKE256). Uses raw 1312-byte public key
// rather than EIP-8051's 20512-byte expanded form; signatures produced for 0x12 on
// EIP-8051-compliant chains are not byte-compatible with this precompile's input.
private static final DataWord verifyMlDsa44Addr = new DataWord(
"0000000000000000000000000000000000000000000000000000000000000012");
// 0x14: ML-DSA-65 verify (TRON extension, FIPS-204 / SHAKE256, raw 1952-byte public key).
private static final DataWord verifyMlDsa65Addr = new DataWord(
"0000000000000000000000000000000000000000000000000000000000000014");

public static PrecompiledContract getOptimizedContractForConstant(PrecompiledContract contract) {
try {
Constructor<?> constructor = contract.getClass().getDeclaredConstructor();
Expand Down Expand Up @@ -282,6 +296,13 @@ public static PrecompiledContract getContractForAddress(DataWord address) {
return blake2F;
}

if (VMConfig.allowMlDsa() && address.equals(verifyMlDsa44Addr)) {
return verifyMlDsa44;
}
if (VMConfig.allowMlDsa() && address.equals(verifyMlDsa65Addr)) {
return verifyMlDsa65;
}

if (VMConfig.allowTvmFreezeV2()) {
if (address.equals(getChainParameterAddr)) {
return getChainParameter;
Expand Down Expand Up @@ -2221,4 +2242,72 @@ public Pair<Boolean, byte[]> execute(byte[] data) {
}
}

/**
* Verifies an ML-DSA-44 signature (FIPS-204, SHAKE256). Input layout (right-padded with
* zeros if shorter): [msg 32B | signature 2420B | publicKey 1312B] = 3764B. Returns a
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Javadoc claims short inputs are "right-padded with zeros" but the implementation rejects them (returns 0). Smart contract developers relying on the documented padding behavior will get silent verification failures. Either remove the padding claim from the doc or implement the padding.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java, line 2247:

<comment>Javadoc claims short inputs are "right-padded with zeros" but the implementation rejects them (returns 0). Smart contract developers relying on the documented padding behavior will get silent verification failures. Either remove the padding claim from the doc or implement the padding.</comment>

<file context>
@@ -2221,4 +2242,72 @@ public Pair<Boolean, byte[]> execute(byte[] data) {
 
+  /**
+   * Verifies an ML-DSA-44 signature (FIPS-204, SHAKE256). Input layout (right-padded with
+   * zeros if shorter): [msg 32B | signature 2420B | publicKey 1312B] = 3764B. Returns a
+   * 32-byte word: 1 on success, 0 on failure or malformed input.
+   */
</file context>
Fix with Cubic

* 32-byte word: 1 on success, 0 on failure or malformed input.
*/
public static class VerifyMlDsa44 extends PrecompiledContract {

private static final int MSG_LEN = 32;
private static final int SIG_LEN = MLDSA44.SIGNATURE_LENGTH;
private static final int PK_LEN = MLDSA44.PUBLIC_KEY_LENGTH;
private static final int INPUT_LEN = MSG_LEN + SIG_LEN + PK_LEN;

@Override
public long getEnergyForData(byte[] data) {
return 4500;
}

@Override
public Pair<Boolean, byte[]> execute(byte[] data) {
if (data == null || data.length < INPUT_LEN) {
return Pair.of(true, DataWord.ZERO().getData());
}
try {
byte[] msg = copyOfRange(data, 0, MSG_LEN);
byte[] sig = copyOfRange(data, MSG_LEN, MSG_LEN + SIG_LEN);
byte[] pk = copyOfRange(data, MSG_LEN + SIG_LEN, INPUT_LEN);
boolean ok = MLDSA44.verify(pk, msg, sig);
return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData());
} catch (Throwable t) {
return Pair.of(true, DataWord.ZERO().getData());
}
}
}

/**
* Verifies an ML-DSA-65 signature (FIPS-204, SHAKE256). Input layout:
* [msg 32B | signature 3309B | publicKey 1952B] = 5293B. TRON extension; not part of
* EIP-8051. Returns a 32-byte word: 1 on success, 0 otherwise.
*/
public static class VerifyMlDsa65 extends PrecompiledContract {

private static final int MSG_LEN = 32;
private static final int SIG_LEN = MLDSA65.SIGNATURE_LENGTH;
private static final int PK_LEN = MLDSA65.PUBLIC_KEY_LENGTH;
private static final int INPUT_LEN = MSG_LEN + SIG_LEN + PK_LEN;

@Override
public long getEnergyForData(byte[] data) {
return 7000;
}

@Override
public Pair<Boolean, byte[]> execute(byte[] data) {
if (data == null || data.length < INPUT_LEN) {
return Pair.of(true, DataWord.ZERO().getData());
}
try {
byte[] msg = copyOfRange(data, 0, MSG_LEN);
byte[] sig = copyOfRange(data, MSG_LEN, MSG_LEN + SIG_LEN);
byte[] pk = copyOfRange(data, MSG_LEN + SIG_LEN, INPUT_LEN);
boolean ok = MLDSA65.verify(pk, msg, sig);
return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData());
} catch (Throwable t) {
return Pair.of(true, DataWord.ZERO().getData());
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public static void load(StoreFactory storeFactory) {
VMConfig.initAllowTvmBlob(ds.getAllowTvmBlob());
VMConfig.initAllowTvmSelfdestructRestriction(ds.getAllowTvmSelfdestructRestriction());
VMConfig.initAllowTvmOsaka(ds.getAllowTvmOsaka());
VMConfig.initAllowMlDsa(ds.getAllowMlDsa());
}
}
}
Expand Down
50 changes: 50 additions & 0 deletions chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,42 @@
import com.google.common.collect.Lists;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.tron.common.crypto.ECKey;
import org.tron.common.crypto.SignInterface;
import org.tron.common.crypto.SignUtils;
import org.tron.common.crypto.pqc.MLDSA65;
import org.tron.common.crypto.pqc.PqSignatureRegistry;
import org.tron.core.config.Parameter.ChainConstant;
import org.tron.core.exception.TronError;
import org.tron.protos.Protocol.SignatureScheme;

@Slf4j(topic = "app")
public class LocalWitnesses {

@Getter
private List<String> privateKeys = Lists.newArrayList();

/** ML-DSA seed values in hex format (64 hex chars = 32 bytes). */
@Getter
private List<String> pqSeeds = Lists.newArrayList();

/** PQ signature scheme used to derive keys from {@link #pqSeeds}. */
@Getter
private SignatureScheme pqScheme = SignatureScheme.ML_DSA_65;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

public void setPqScheme(SignatureScheme pqScheme) {
if (pqScheme == null || !PqSignatureRegistry.contains(pqScheme)) {
throw new TronError("unsupported PQ signature scheme: " + pqScheme,
TronError.ErrCode.WITNESS_INIT);
}
this.pqScheme = pqScheme;
}

@Setter
@Getter
private byte[] witnessAccountAddress;

Expand Down Expand Up @@ -95,6 +116,35 @@ public void addPrivateKeys(String privateKey) {
this.privateKeys.add(privateKey);
}

/** ML-DSA seed values (32 bytes = 64 hex chars). Keys are derived from seeds. */
public void setPqSeeds(final List<String> pqSeeds) {
if (CollectionUtils.isEmpty(pqSeeds)) {
return;
}
for (String seed : pqSeeds) {
validatePqSeed(seed);
}
this.pqSeeds = pqSeeds;
}

private static void validatePqSeed(String seed) {
String hex = seed;
// Match downstream ByteArray.fromHexString, which only strips lowercase "0x".
if (StringUtils.startsWith(hex, "0x")) {
hex = hex.substring(2);
}
int expectedHexLen = MLDSA65.SEED_LENGTH * 2;
if (StringUtils.isBlank(hex) || hex.length() != expectedHexLen) {
throw new TronError(String.format("ML-DSA seed must be %d hex chars, actual: %d",
expectedHexLen, StringUtils.isBlank(hex) ? 0 : hex.length()),
TronError.ErrCode.WITNESS_INIT);
}
if (!StringUtil.isHexadecimal(hex)) {
throw new TronError("ML-DSA seed must be hex string",
TronError.ErrCode.WITNESS_INIT);
}
}

//get the first one recently
public String getPrivateKey() {
if (CollectionUtils.isEmpty(privateKeys)) {
Expand Down
Loading
Loading