From 2c3e112799467a3502fe18a3854a3bd07c617444 Mon Sep 17 00:00:00 2001 From: Asuka Date: Tue, 14 Apr 2026 18:07:54 +0800 Subject: [PATCH 1/2] feat(vm): canonicalize calldata length for validateMultiSign / batchValidateSign under Osaka Under allowTvmOsaka, both precompiles now reject calldata that does not match the shape implied by the existing getEnergyForData formula (words - H) / I: the byte length must be a multiple of 32, at least as long as the H-word static head, and the remaining tail must be divisible by the per-item width (H=5, I=5 for validateMultiSign; H=5, I=6 for batchValidateSign). The check lives only in execute(...). Rejected calldata returns Pair.of(false, EMPTY_BYTE_ARRAY), so the enclosing CALL is taken through the normal precompile-failure path in Program#callToPrecompiledAddress: refundEnergy(0) leaves the CALL's pre-allocated energy consumed, the stack receives 0 and memory gets no return data, just like any other unsuccessful inner call. That is strictly better than the pre-Osaka behaviour where malformed calldata surfaced as an AIOOBE, escaped through the RuntimeException handler in VM#play, and triggered spendAllEnergy - draining the whole enclosing transaction rather than only this CALL frame. Returning false also short-circuits the success-branch refund, so the legacy formula's negative result on too-short calldata can never be observed as an energy credit. getEnergyForData and the energy formula are unchanged. --- .../tron/core/vm/PrecompiledContracts.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 634f7f2d3d..8ba167015f 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -414,6 +414,14 @@ private static byte[] extractBytes(byte[] data, int offset, int len) { return Arrays.copyOfRange(data, offset, offset + len); } + private static boolean isValidAbiEncoding(byte[] data, int headerWords, int itemWords) { + if (data == null || data.length % WORD_SIZE != 0) { + return false; + } + long tail = (long) data.length - (long) headerWords * WORD_SIZE; + return tail >= 0 && tail % ((long) itemWords * WORD_SIZE) == 0; + } + public abstract static class PrecompiledContract { protected static final byte[] DATA_FALSE = new byte[WORD_SIZE]; @@ -938,6 +946,8 @@ public static class ValidateMultiSign extends PrecompiledContract { private static final int ENGERYPERSIGN = 1500; private static final int MAX_SIZE = 5; + private static final int ABI_HEADER_WORDS = 5; + private static final int ABI_ITEM_WORDS = 5; @Override @@ -949,6 +959,10 @@ public long getEnergyForData(byte[] data) { @Override public Pair execute(byte[] rawData) { + if (VMConfig.allowTvmOsaka() + && !isValidAbiEncoding(rawData, ABI_HEADER_WORDS, ABI_ITEM_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } DataWord[] words = DataWord.parseArray(rawData); byte[] address = words[0].toTronAddress(); int permissionId = words[1].intValueSafe(); @@ -1021,6 +1035,8 @@ public static class BatchValidateSign extends PrecompiledContract { private static final String workersName = "validate-sign-contract"; private static final int ENGERYPERSIGN = 1500; private static final int MAX_SIZE = 16; + private static final int ABI_HEADER_WORDS = 5; + private static final int ABI_ITEM_WORDS = 6; static { workers = ExecutorServiceManager.newFixedThreadPool(workersName, @@ -1048,6 +1064,10 @@ public Pair execute(byte[] data) { private Pair doExecute(byte[] data) throws InterruptedException, ExecutionException { + if (VMConfig.allowTvmOsaka() + && !isValidAbiEncoding(data, ABI_HEADER_WORDS, ABI_ITEM_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } DataWord[] words = DataWord.parseArray(data); byte[] hash = words[0].getData(); From 3d45f40c9029ae9eb29cc91b6526d73594676e03 Mon Sep 17 00:00:00 2001 From: Asuka Date: Fri, 24 Apr 2026 14:44:14 +0800 Subject: [PATCH 2/2] test(vm): cover TIP-854 calldata canonicalization for sign-validation precompiles Add regression tests for the Osaka-gated length guard on validateMultiSign and batchValidateSign. Each precompile's existing unit test file gets three new @Test methods covering the four buckets from the TIP discussion: - non-32-aligned length (r = 1, 31) - fewer than H = 5 static-head words (lengths 0, 32, 64, 96, 128) - 32-aligned but tail not a multiple of I (I = 5 / 6) - null calldata - canonicalized well-formed input unchanged pre- vs post-activation - pre-activation behaviour (legacy decoder path / outer catch) preserved OperationsTest gets one integration test that drives the reject through Program#callToPrecompiledAddress and asserts the outer-frame containment invariant: inner CALL pushes 0, the outer frame sees no propagated exception, and subsequent stack operations still succeed. Every test resets allowTvmOsaka to 0 in finally so state does not leak to neighbouring tests. --- .../vm/BatchValidateSignContractTest.java | 82 ++++++++++++++ .../common/runtime/vm/OperationsTest.java | 43 ++++++++ .../vm/ValidateMultiSignContractTest.java | 104 ++++++++++++++++++ 3 files changed, 229 insertions(+) diff --git a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignContractTest.java b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignContractTest.java index c18eb39654..8849e114c9 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignContractTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignContractTest.java @@ -10,6 +10,7 @@ import org.junit.Test; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.Hash; +import org.tron.common.utils.ByteUtil; import org.tron.common.utils.StringUtil; import org.tron.common.utils.client.utils.AbiUtil; import org.tron.core.db.TransactionTrace; @@ -130,6 +131,87 @@ public void correctionTest() { System.gc(); // force triggering full gc to avoid timeout for next test } + // TIP-854: after activation, batchValidateSign (H=5, I=6) must reject calldata + // whose byte length is incompatible with the (words - 5) / 6 shape the per-call + // energy formula already assumes, returning (false, empty). The guard lives in + // doExecute(); the outer try/catch does not mask it because the guard does not + // throw (pure arithmetic + a static getter). + @Test + public void testTip854RejectsMalformedCalldata() { + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 2_000_000); + VMConfig.initAllowTvmOsaka(1); + try { + // Bucket 1: 32-aligned head + sub-word trailing bytes (r=1, r=31). + for (int r : new int[]{1, 31}) { + byte[] data = new byte[(5 + 6) * 32 + r]; + Pair ret = contract.execute(data); + Assert.assertFalse("non-32-aligned len=" + data.length, ret.getLeft()); + Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } + // Bucket 2: fewer than the static head's 5 words. + for (int bytes : new int[]{0, 32, 64, 96, 128}) { + Pair ret = contract.execute(new byte[bytes]); + Assert.assertFalse("len=" + bytes + " < 5 words", ret.getLeft()); + Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } + // Bucket 3: 32-aligned but tail not a multiple of I=6 words (k = 1..5). + for (int k = 1; k <= 5; k++) { + byte[] data = new byte[(5 + k) * 32]; + Pair ret = contract.execute(data); + Assert.assertFalse("aligned bad-tail k=" + k, ret.getLeft()); + Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } + // Null calldata: explicit spec clause. + Pair ret = contract.execute(null); + Assert.assertFalse("null calldata", ret.getLeft()); + Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } finally { + VMConfig.initAllowTvmOsaka(0); + } + System.gc(); + } + + // TIP-854 Compatibility: for canonically-shaped calldata — all 65-byte real + // signatures so each bytes[i] encodes in exactly 4 words (1 length + 3 content) + // — total length equals 5*32 + 6*32*N, so pre- and post-activation must be + // observationally identical. + @Test + public void testTip854CanonicalInputUnchanged() { + contract.setConstantCall(true); + List signatures = new ArrayList<>(); + List addresses = new ArrayList<>(); + byte[] hash = Hash.sha3(longData); + for (int i = 0; i < 8; i++) { + ECKey key = new ECKey(); + signatures.add(Hex.toHexString(key.sign(hash).toByteArray())); + addresses.add(StringUtil.encode58Check(key.getAddress())); + } + + VMConfig.initAllowTvmOsaka(0); + Pair pre = validateMultiSign(hash, signatures, addresses); + VMConfig.initAllowTvmOsaka(1); + try { + Pair post = validateMultiSign(hash, signatures, addresses); + Assert.assertEquals(pre.getLeft(), post.getLeft()); + Assert.assertArrayEquals(pre.getValue(), post.getValue()); + } finally { + VMConfig.initAllowTvmOsaka(0); + } + System.gc(); + } + + // TIP-854: before activation the guard is not consulted. Malformed calldata + // that would raise inside doExecute gets collapsed to (true, 32-byte zero) by + // the outer catch — this is the legacy behaviour and must be preserved. + @Test + public void testTip854PreActivationNoOp() { + VMConfig.initAllowTvmOsaka(0); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 2_000_000); + Pair ret = contract.execute(new byte[(5 + 1) * 32]); + Assert.assertTrue("pre-activation must not take the new reject path", ret.getLeft()); + Assert.assertEquals(32, ret.getRight().length); + } + Pair validateMultiSign(byte[] hash, List signatures, List addresses) { List parameters = Arrays.asList("0x" + Hex.toHexString(hash), signatures, addresses); diff --git a/framework/src/test/java/org/tron/common/runtime/vm/OperationsTest.java b/framework/src/test/java/org/tron/common/runtime/vm/OperationsTest.java index 583b013194..8b405c4123 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/OperationsTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/OperationsTest.java @@ -786,6 +786,49 @@ Op.CALL, new DataWord(10000), VMConfig.initAllowTvmSelfdestructRestriction(0); } + // TIP-854 outer-frame containment: a CALL to validateMultiSign or + // batchValidateSign with malformed calldata must (a) push 0 onto the outer + // stack, (b) leave the outer frame free of any propagated exception, and + // (c) allow the outer frame to continue executing afterwards. + @Test + public void testTip854OuterFrameContainment() throws ContractValidateException { + byte prePrefixByte = DecodeUtil.addressPreFixByte; + DecodeUtil.addressPreFixByte = Constant.ADD_PRE_FIX_BYTE_MAINNET; + VMConfig.initAllowTvmOsaka(1); + try { + for (PrecompiledContracts.PrecompiledContract contract : + new PrecompiledContracts.PrecompiledContract[]{ + new PrecompiledContracts.ValidateMultiSign(), + new PrecompiledContracts.BatchValidateSign()}) { + invoke = new ProgramInvokeMockImpl(); + InternalTransaction interTrx = new InternalTransaction( + Protocol.Transaction.getDefaultInstance(), + InternalTransaction.TrxType.TRX_UNKNOWN_TYPE); + program = new Program(new byte[0], new byte[0], invoke, interTrx); + // inDataSize=0 ⇒ data=[] ⇒ fewer than H=5 head words ⇒ guard rejects. + MessageCall messageCall = new MessageCall( + Op.CALL, new DataWord(10000), + DataWord.ZERO(), DataWord.ZERO(), + DataWord.ZERO(), DataWord.ZERO(), + DataWord.ZERO(), DataWord.ZERO(), + DataWord.ZERO(), false); + program.callToPrecompiledAddress(messageCall, contract); + + Assert.assertNull(contract.getClass().getSimpleName() + + ": outer frame must not inherit an exception", + program.getResult().getException()); + Assert.assertEquals(contract.getClass().getSimpleName() + ": inner CALL pushes 0", + DataWord.ZERO(), program.getStack().pop()); + // Outer frame continues: another stack op works without throwing. + program.stackPush(new DataWord(1)); + Assert.assertEquals(new DataWord(1), program.getStack().pop()); + } + } finally { + VMConfig.initAllowTvmOsaka(0); + DecodeUtil.addressPreFixByte = prePrefixByte; + } + } + @Test public void testOtherOperations() throws ContractValidateException { invoke = new ProgramInvokeMockImpl(); diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignContractTest.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignContractTest.java index 518d42041e..d7ccab73bd 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignContractTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignContractTest.java @@ -155,6 +155,110 @@ public void testDifferentCase() { } + // TIP-854: after activation, validateMultiSign (H=5, I=5) must reject calldata + // whose byte length is incompatible with the (words - 5) / 5 shape the per-call + // energy formula already assumes, returning (false, empty). + @Test + public void testTip854RejectsMalformedCalldata() { + VMConfig.initAllowTvmOsaka(1); + try { + // Bucket 1: 32-aligned head + sub-word trailing bytes (r=1, r=31). + for (int r : new int[]{1, 31}) { + byte[] data = new byte[(5 + 5) * 32 + r]; + Pair ret = contract.execute(data); + Assert.assertFalse("non-32-aligned len=" + data.length, ret.getLeft()); + Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } + // Bucket 2: fewer than the static head's 5 words. + for (int bytes : new int[]{0, 32, 64, 96, 128}) { + Pair ret = contract.execute(new byte[bytes]); + Assert.assertFalse("len=" + bytes + " < 5 words", ret.getLeft()); + Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } + // Bucket 3: 32-aligned but tail not a multiple of I=5 words (k = 1..4). + for (int k = 1; k <= 4; k++) { + byte[] data = new byte[(5 + k) * 32]; + Pair ret = contract.execute(data); + Assert.assertFalse("aligned bad-tail k=" + k, ret.getLeft()); + Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } + // Null calldata: explicit spec clause. + Pair ret = contract.execute(null); + Assert.assertFalse("null calldata", ret.getLeft()); + Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } finally { + VMConfig.initAllowTvmOsaka(0); + } + } + + // TIP-854 Compatibility: for canonically-shaped calldata (real 65-byte sigs, + // total length == 5*32 + 5*32*N), behaviour must be identical pre- vs + // post-activation — the guard is a no-op for well-formed inputs. + @Test + public void testTip854CanonicalInputUnchanged() { + ECKey key = new ECKey(); + AccountCapsule toAccount = new AccountCapsule(ByteString.copyFrom(key.getAddress()), + Protocol.AccountType.Normal, + System.currentTimeMillis(), true, dbManager.getDynamicPropertiesStore()); + ECKey key1 = new ECKey(); + ECKey key2 = new ECKey(); + Protocol.Permission activePermission = + Protocol.Permission.newBuilder() + .setType(Protocol.Permission.PermissionType.Active) + .setId(2) + .setPermissionName("active") + .setThreshold(2) + .setOperations(ByteString.copyFrom(ByteArray + .fromHexString("0000000000000000000000000000000000000000000000000000000000000000"))) + .addKeys(Protocol.Key.newBuilder().setAddress(ByteString.copyFrom(key1.getAddress())) + .setWeight(1).build()) + .addKeys(Protocol.Key.newBuilder().setAddress(ByteString.copyFrom(key2.getAddress())) + .setWeight(1).build()) + .build(); + toAccount.updatePermissions(toAccount.getPermissionById(0), null, + Collections.singletonList(activePermission)); + dbManager.getAccountStore().put(key.getAddress(), toAccount); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] merged = ByteUtil.merge(key.getAddress(), ByteArray.fromInt(2), data); + byte[] toSign = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), merged); + List signs = new ArrayList<>(); + signs.add(Hex.toHexString(key1.sign(toSign).toByteArray())); + signs.add(Hex.toHexString(key2.sign(toSign).toByteArray())); + + VMConfig.initAllowTvmOsaka(0); + Pair pre = + validateMultiSign(StringUtil.encode58Check(key.getAddress()), 2, data, signs); + VMConfig.initAllowTvmOsaka(1); + try { + Pair post = + validateMultiSign(StringUtil.encode58Check(key.getAddress()), 2, data, signs); + Assert.assertEquals(pre.getLeft(), post.getLeft()); + Assert.assertArrayEquals(pre.getValue(), post.getValue()); + Assert.assertArrayEquals(DataWord.ONE().getData(), post.getValue()); + } finally { + VMConfig.initAllowTvmOsaka(0); + } + } + + // TIP-854: before activation, malformed calldata reaches the legacy decoder. + // Assert the guard is not taken — this precompile has no outer catch, so a + // too-short input raises inside the decoder; that is the documented + // pre-activation failure mode the TIP explicitly preserves. + @Test + public void testTip854PreActivationNoOp() { + VMConfig.initAllowTvmOsaka(0); + contract.setRepository(RepositoryImpl.createRoot(StoreFactory.getInstance())); + try { + Pair ret = contract.execute(new byte[(5 + 1) * 32]); + // If the decoder happened to handle it without raising, we must not have + // taken the post-activation reject path (false, empty). + Assert.assertNotSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight()); + } catch (RuntimeException expectedLegacyBehaviour) { + // Pre-activation: decoder may throw — this is the existing behaviour. + } + } + Pair validateMultiSign(String address, int permissionId, byte[] hash, List signatures) { List parameters = Arrays