Skip to content

Commit e178733

Browse files
Merge pull request #6715 from yanghang8612/feat/precompile-canonical-input
feat(vm): implement TIP-854 canonicalize sign-precompile calldata
2 parents b1522fa + 0f9fc76 commit e178733

4 files changed

Lines changed: 251 additions & 0 deletions

File tree

actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import static java.util.Arrays.copyOfRange;
44
import static org.tron.common.math.Maths.max;
55
import static org.tron.common.math.Maths.min;
6+
import static org.tron.common.math.StrictMathWrapper.multiplyExact;
7+
import static org.tron.common.math.StrictMathWrapper.subtractExact;
68
import static org.tron.common.runtime.vm.DataWord.WORD_SIZE;
79
import static org.tron.common.utils.BIUtil.addSafely;
810
import static org.tron.common.utils.BIUtil.isLessThan;
@@ -427,6 +429,14 @@ private static byte[] extractBytes(byte[] data, int offset, int len) {
427429
return Arrays.copyOfRange(data, offset, offset + len);
428430
}
429431

432+
private static boolean isValidAbiEncoding(byte[] data, int headerWords, int itemWords) {
433+
if (data == null || data.length % WORD_SIZE != 0) {
434+
return false;
435+
}
436+
long tail = subtractExact(data.length, multiplyExact(headerWords, WORD_SIZE));
437+
return tail > 0 && tail % multiplyExact(itemWords, WORD_SIZE) == 0;
438+
}
439+
430440
public abstract static class PrecompiledContract {
431441

432442
protected static final byte[] DATA_FALSE = new byte[WORD_SIZE];
@@ -1020,6 +1030,8 @@ public static class ValidateMultiSign extends PrecompiledContract {
10201030

10211031
private static final int ENGERYPERSIGN = 1500;
10221032
private static final int MAX_SIZE = 5;
1033+
private static final int ABI_HEADER_WORDS = 5;
1034+
private static final int ABI_ITEM_WORDS = 5;
10231035

10241036

10251037
@Override
@@ -1031,6 +1043,10 @@ public long getEnergyForData(byte[] data) {
10311043

10321044
@Override
10331045
public Pair<Boolean, byte[]> execute(byte[] rawData) {
1046+
if (VMConfig.allowTvmOsaka()
1047+
&& !isValidAbiEncoding(rawData, ABI_HEADER_WORDS, ABI_ITEM_WORDS)) {
1048+
return Pair.of(false, EMPTY_BYTE_ARRAY);
1049+
}
10341050
DataWord[] words = DataWord.parseArray(rawData);
10351051
byte[] address = words[0].toTronAddress();
10361052
int permissionId = words[1].intValueSafe();
@@ -1103,6 +1119,8 @@ public static class BatchValidateSign extends PrecompiledContract {
11031119
private static final String workersName = "validate-sign-contract";
11041120
private static final int ENGERYPERSIGN = 1500;
11051121
private static final int MAX_SIZE = 16;
1122+
private static final int ABI_HEADER_WORDS = 5;
1123+
private static final int ABI_ITEM_WORDS = 6;
11061124

11071125
static {
11081126
workers = ExecutorServiceManager.newFixedThreadPool(workersName,
@@ -1130,6 +1148,10 @@ public Pair<Boolean, byte[]> execute(byte[] data) {
11301148

11311149
private Pair<Boolean, byte[]> doExecute(byte[] data)
11321150
throws InterruptedException, ExecutionException {
1151+
if (VMConfig.allowTvmOsaka()
1152+
&& !isValidAbiEncoding(data, ABI_HEADER_WORDS, ABI_ITEM_WORDS)) {
1153+
return Pair.of(false, EMPTY_BYTE_ARRAY);
1154+
}
11331155
DataWord[] words = DataWord.parseArray(data);
11341156
byte[] hash = words[0].getData();
11351157

framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignContractTest.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.junit.Test;
1111
import org.tron.common.crypto.ECKey;
1212
import org.tron.common.crypto.Hash;
13+
import org.tron.common.utils.ByteUtil;
1314
import org.tron.common.utils.StringUtil;
1415
import org.tron.common.utils.client.utils.AbiUtil;
1516
import org.tron.core.db.TransactionTrace;
@@ -130,6 +131,87 @@ public void correctionTest() {
130131
System.gc(); // force triggering full gc to avoid timeout for next test
131132
}
132133

134+
// TIP-854: after activation, batchValidateSign (H=5, I=6) must reject calldata
135+
// whose byte length is incompatible with the (words - 5) / 6 shape the per-call
136+
// energy formula already assumes, returning (false, empty). The guard lives in
137+
// doExecute(); the outer try/catch does not mask it because the guard does not
138+
// throw (pure arithmetic + a static getter).
139+
@Test
140+
public void testTip854RejectsMalformedCalldata() {
141+
contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 2_000_000);
142+
VMConfig.initAllowTvmOsaka(1);
143+
try {
144+
// Bucket 1: 32-aligned head + sub-word trailing bytes (r=1, r=31).
145+
for (int r : new int[]{1, 31}) {
146+
byte[] data = new byte[(5 + 6) * 32 + r];
147+
Pair<Boolean, byte[]> ret = contract.execute(data);
148+
Assert.assertFalse("non-32-aligned len=" + data.length, ret.getLeft());
149+
Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
150+
}
151+
// Bucket 2: fewer than the static head's 5 words.
152+
for (int bytes : new int[]{0, 32, 64, 96, 128}) {
153+
Pair<Boolean, byte[]> ret = contract.execute(new byte[bytes]);
154+
Assert.assertFalse("len=" + bytes + " < 5 words", ret.getLeft());
155+
Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
156+
}
157+
// Bucket 3: 32-aligned but tail not a multiple of I=6 words (k = 1..5).
158+
for (int k = 1; k <= 5; k++) {
159+
byte[] data = new byte[(5 + k) * 32];
160+
Pair<Boolean, byte[]> ret = contract.execute(data);
161+
Assert.assertFalse("aligned bad-tail k=" + k, ret.getLeft());
162+
Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
163+
}
164+
// Null calldata: explicit spec clause.
165+
Pair<Boolean, byte[]> ret = contract.execute(null);
166+
Assert.assertFalse("null calldata", ret.getLeft());
167+
Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
168+
} finally {
169+
VMConfig.initAllowTvmOsaka(0);
170+
}
171+
System.gc();
172+
}
173+
174+
// TIP-854 Compatibility: for canonically-shaped calldata — all 65-byte real
175+
// signatures so each bytes[i] encodes in exactly 4 words (1 length + 3 content)
176+
// — total length equals 5*32 + 6*32*N, so pre- and post-activation must be
177+
// observationally identical.
178+
@Test
179+
public void testTip854CanonicalInputUnchanged() {
180+
contract.setConstantCall(true);
181+
List<Object> signatures = new ArrayList<>();
182+
List<Object> addresses = new ArrayList<>();
183+
byte[] hash = Hash.sha3(longData);
184+
for (int i = 0; i < 8; i++) {
185+
ECKey key = new ECKey();
186+
signatures.add(Hex.toHexString(key.sign(hash).toByteArray()));
187+
addresses.add(StringUtil.encode58Check(key.getAddress()));
188+
}
189+
190+
VMConfig.initAllowTvmOsaka(0);
191+
Pair<Boolean, byte[]> pre = validateMultiSign(hash, signatures, addresses);
192+
VMConfig.initAllowTvmOsaka(1);
193+
try {
194+
Pair<Boolean, byte[]> post = validateMultiSign(hash, signatures, addresses);
195+
Assert.assertEquals(pre.getLeft(), post.getLeft());
196+
Assert.assertArrayEquals(pre.getValue(), post.getValue());
197+
} finally {
198+
VMConfig.initAllowTvmOsaka(0);
199+
}
200+
System.gc();
201+
}
202+
203+
// TIP-854: before activation the guard is not consulted. Malformed calldata
204+
// that would raise inside doExecute gets collapsed to (true, 32-byte zero) by
205+
// the outer catch — this is the legacy behaviour and must be preserved.
206+
@Test
207+
public void testTip854PreActivationNoOp() {
208+
VMConfig.initAllowTvmOsaka(0);
209+
contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 2_000_000);
210+
Pair<Boolean, byte[]> ret = contract.execute(new byte[(5 + 1) * 32]);
211+
Assert.assertTrue("pre-activation must not take the new reject path", ret.getLeft());
212+
Assert.assertEquals(32, ret.getRight().length);
213+
}
214+
133215
Pair<Boolean, byte[]> validateMultiSign(byte[] hash, List<Object> signatures,
134216
List<Object> addresses) {
135217
List<Object> parameters = Arrays.asList("0x" + Hex.toHexString(hash), signatures, addresses);

framework/src/test/java/org/tron/common/runtime/vm/OperationsTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,49 @@ Op.CALL, new DataWord(10000),
788788
VMConfig.initAllowTvmSelfdestructRestriction(0);
789789
}
790790

791+
// TIP-854 outer-frame containment: a CALL to validateMultiSign or
792+
// batchValidateSign with malformed calldata must (a) push 0 onto the outer
793+
// stack, (b) leave the outer frame free of any propagated exception, and
794+
// (c) allow the outer frame to continue executing afterwards.
795+
@Test
796+
public void testTip854OuterFrameContainment() throws ContractValidateException {
797+
byte prePrefixByte = DecodeUtil.addressPreFixByte;
798+
DecodeUtil.addressPreFixByte = Constant.ADD_PRE_FIX_BYTE_MAINNET;
799+
VMConfig.initAllowTvmOsaka(1);
800+
try {
801+
for (PrecompiledContracts.PrecompiledContract contract :
802+
new PrecompiledContracts.PrecompiledContract[]{
803+
new PrecompiledContracts.ValidateMultiSign(),
804+
new PrecompiledContracts.BatchValidateSign()}) {
805+
invoke = new ProgramInvokeMockImpl();
806+
InternalTransaction interTrx = new InternalTransaction(
807+
Protocol.Transaction.getDefaultInstance(),
808+
InternalTransaction.TrxType.TRX_UNKNOWN_TYPE);
809+
program = new Program(new byte[0], new byte[0], invoke, interTrx);
810+
// inDataSize=0 ⇒ data=[] ⇒ fewer than H=5 head words ⇒ guard rejects.
811+
MessageCall messageCall = new MessageCall(
812+
Op.CALL, new DataWord(10000),
813+
DataWord.ZERO(), DataWord.ZERO(),
814+
DataWord.ZERO(), DataWord.ZERO(),
815+
DataWord.ZERO(), DataWord.ZERO(),
816+
DataWord.ZERO(), false);
817+
program.callToPrecompiledAddress(messageCall, contract);
818+
819+
Assert.assertNull(contract.getClass().getSimpleName()
820+
+ ": outer frame must not inherit an exception",
821+
program.getResult().getException());
822+
Assert.assertEquals(contract.getClass().getSimpleName() + ": inner CALL pushes 0",
823+
DataWord.ZERO(), program.getStack().pop());
824+
// Outer frame continues: another stack op works without throwing.
825+
program.stackPush(new DataWord(1));
826+
Assert.assertEquals(new DataWord(1), program.getStack().pop());
827+
}
828+
} finally {
829+
VMConfig.initAllowTvmOsaka(0);
830+
DecodeUtil.addressPreFixByte = prePrefixByte;
831+
}
832+
}
833+
791834
@Test
792835
public void testOtherOperations() throws ContractValidateException {
793836
invoke = new ProgramInvokeMockImpl();

framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignContractTest.java

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,110 @@ public void testDifferentCase() {
155155
}
156156

157157

158+
// TIP-854: after activation, validateMultiSign (H=5, I=5) must reject calldata
159+
// whose byte length is incompatible with the (words - 5) / 5 shape the per-call
160+
// energy formula already assumes, returning (false, empty).
161+
@Test
162+
public void testTip854RejectsMalformedCalldata() {
163+
VMConfig.initAllowTvmOsaka(1);
164+
try {
165+
// Bucket 1: 32-aligned head + sub-word trailing bytes (r=1, r=31).
166+
for (int r : new int[]{1, 31}) {
167+
byte[] data = new byte[(5 + 5) * 32 + r];
168+
Pair<Boolean, byte[]> ret = contract.execute(data);
169+
Assert.assertFalse("non-32-aligned len=" + data.length, ret.getLeft());
170+
Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
171+
}
172+
// Bucket 2: fewer than the static head's 5 words.
173+
for (int bytes : new int[]{0, 32, 64, 96, 128}) {
174+
Pair<Boolean, byte[]> ret = contract.execute(new byte[bytes]);
175+
Assert.assertFalse("len=" + bytes + " < 5 words", ret.getLeft());
176+
Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
177+
}
178+
// Bucket 3: 32-aligned but tail not a multiple of I=5 words (k = 1..4).
179+
for (int k = 1; k <= 4; k++) {
180+
byte[] data = new byte[(5 + k) * 32];
181+
Pair<Boolean, byte[]> ret = contract.execute(data);
182+
Assert.assertFalse("aligned bad-tail k=" + k, ret.getLeft());
183+
Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
184+
}
185+
// Null calldata: explicit spec clause.
186+
Pair<Boolean, byte[]> ret = contract.execute(null);
187+
Assert.assertFalse("null calldata", ret.getLeft());
188+
Assert.assertSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
189+
} finally {
190+
VMConfig.initAllowTvmOsaka(0);
191+
}
192+
}
193+
194+
// TIP-854 Compatibility: for canonically-shaped calldata (real 65-byte sigs,
195+
// total length == 5*32 + 5*32*N), behaviour must be identical pre- vs
196+
// post-activation — the guard is a no-op for well-formed inputs.
197+
@Test
198+
public void testTip854CanonicalInputUnchanged() {
199+
ECKey key = new ECKey();
200+
AccountCapsule toAccount = new AccountCapsule(ByteString.copyFrom(key.getAddress()),
201+
Protocol.AccountType.Normal,
202+
System.currentTimeMillis(), true, dbManager.getDynamicPropertiesStore());
203+
ECKey key1 = new ECKey();
204+
ECKey key2 = new ECKey();
205+
Protocol.Permission activePermission =
206+
Protocol.Permission.newBuilder()
207+
.setType(Protocol.Permission.PermissionType.Active)
208+
.setId(2)
209+
.setPermissionName("active")
210+
.setThreshold(2)
211+
.setOperations(ByteString.copyFrom(ByteArray
212+
.fromHexString("0000000000000000000000000000000000000000000000000000000000000000")))
213+
.addKeys(Protocol.Key.newBuilder().setAddress(ByteString.copyFrom(key1.getAddress()))
214+
.setWeight(1).build())
215+
.addKeys(Protocol.Key.newBuilder().setAddress(ByteString.copyFrom(key2.getAddress()))
216+
.setWeight(1).build())
217+
.build();
218+
toAccount.updatePermissions(toAccount.getPermissionById(0), null,
219+
Collections.singletonList(activePermission));
220+
dbManager.getAccountStore().put(key.getAddress(), toAccount);
221+
222+
byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData);
223+
byte[] merged = ByteUtil.merge(key.getAddress(), ByteArray.fromInt(2), data);
224+
byte[] toSign = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), merged);
225+
List<Object> signs = new ArrayList<>();
226+
signs.add(Hex.toHexString(key1.sign(toSign).toByteArray()));
227+
signs.add(Hex.toHexString(key2.sign(toSign).toByteArray()));
228+
229+
VMConfig.initAllowTvmOsaka(0);
230+
Pair<Boolean, byte[]> pre =
231+
validateMultiSign(StringUtil.encode58Check(key.getAddress()), 2, data, signs);
232+
VMConfig.initAllowTvmOsaka(1);
233+
try {
234+
Pair<Boolean, byte[]> post =
235+
validateMultiSign(StringUtil.encode58Check(key.getAddress()), 2, data, signs);
236+
Assert.assertEquals(pre.getLeft(), post.getLeft());
237+
Assert.assertArrayEquals(pre.getValue(), post.getValue());
238+
Assert.assertArrayEquals(DataWord.ONE().getData(), post.getValue());
239+
} finally {
240+
VMConfig.initAllowTvmOsaka(0);
241+
}
242+
}
243+
244+
// TIP-854: before activation, malformed calldata reaches the legacy decoder.
245+
// Assert the guard is not taken — this precompile has no outer catch, so a
246+
// too-short input raises inside the decoder; that is the documented
247+
// pre-activation failure mode the TIP explicitly preserves.
248+
@Test
249+
public void testTip854PreActivationNoOp() {
250+
VMConfig.initAllowTvmOsaka(0);
251+
contract.setRepository(RepositoryImpl.createRoot(StoreFactory.getInstance()));
252+
try {
253+
Pair<Boolean, byte[]> ret = contract.execute(new byte[(5 + 1) * 32]);
254+
// If the decoder happened to handle it without raising, we must not have
255+
// taken the post-activation reject path (false, empty).
256+
Assert.assertNotSame(ByteUtil.EMPTY_BYTE_ARRAY, ret.getRight());
257+
} catch (RuntimeException expectedLegacyBehaviour) {
258+
// Pre-activation: decoder may throw — this is the existing behaviour.
259+
}
260+
}
261+
158262
Pair<Boolean, byte[]> validateMultiSign(String address, int permissionId, byte[] hash,
159263
List<Object> signatures) {
160264
List<Object> parameters = Arrays

0 commit comments

Comments
 (0)