-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(vm): add ABI semantic validation for /wallet/deploycontract #6703
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| package org.tron.core.utils; | ||
|
|
||
| import com.google.common.collect.ImmutableSet; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import java.util.regex.Matcher; | ||
| import java.util.regex.Pattern; | ||
| import org.tron.core.exception.ContractValidateException; | ||
| import org.tron.protos.contract.SmartContractOuterClass.SmartContract.ABI; | ||
| import org.tron.protos.contract.SmartContractOuterClass.SmartContract.ABI.Entry; | ||
| import org.tron.protos.contract.SmartContractOuterClass.SmartContract.ABI.Entry.EntryType; | ||
| import org.tron.protos.contract.SmartContractOuterClass.SmartContract.ABI.Entry.Param; | ||
| import org.tron.protos.contract.SmartContractOuterClass.SmartContract.ABI.Entry.StateMutabilityType; | ||
|
|
||
| public final class AbiValidator { | ||
|
|
||
| private static final Pattern INT_TYPE = Pattern.compile("^(u?int)(\\d*)$"); | ||
| private static final Pattern BYTES_N_TYPE = Pattern.compile("^bytes(\\d+)$"); | ||
| private static final Pattern ARRAY_SUFFIX = Pattern.compile("\\[(\\d*)]$"); | ||
|
|
||
| private static final Set<String> BASE_TYPES = ImmutableSet.of( | ||
| "address", "bool", "string", "bytes", "function", "tuple", "trcToken"); | ||
|
|
||
| private AbiValidator() { | ||
| } | ||
|
|
||
| public static void validate(ABI abi) throws ContractValidateException { | ||
| if (abi == null || abi.getEntrysCount() == 0) { | ||
| return; | ||
| } | ||
|
|
||
| int constructorCount = 0; | ||
| int fallbackCount = 0; | ||
| int receiveCount = 0; | ||
|
|
||
| for (int i = 0; i < abi.getEntrysCount(); i++) { | ||
| Entry entry = abi.getEntrys(i); | ||
| EntryType type = entry.getType(); | ||
|
|
||
| if (type == EntryType.UnknownEntryType || type == EntryType.UNRECOGNIZED) { | ||
| throw new ContractValidateException( | ||
| String.format("abi entry #%d: unknown entry type", i)); | ||
| } | ||
|
|
||
| switch (type) { | ||
| case Constructor: | ||
| constructorCount++; | ||
| break; | ||
| case Fallback: | ||
| fallbackCount++; | ||
| if (entry.getInputsCount() > 0 || entry.getOutputsCount() > 0) { | ||
| throw new ContractValidateException(String.format( | ||
| "abi entry #%d: fallback function must not have inputs or outputs", i)); | ||
| } | ||
| break; | ||
| case Receive: | ||
| receiveCount++; | ||
| if (entry.getInputsCount() > 0 || entry.getOutputsCount() > 0) { | ||
| throw new ContractValidateException(String.format( | ||
| "abi entry #%d: receive function must not have inputs or outputs", i)); | ||
| } | ||
| if (entry.getStateMutability() != StateMutabilityType.Payable && !entry.getPayable()) { | ||
| throw new ContractValidateException(String.format( | ||
| "abi entry #%d: receive function must be payable", i)); | ||
| } | ||
| break; | ||
| case Function: | ||
| if (entry.getName().isEmpty()) { | ||
| throw new ContractValidateException(String.format( | ||
| "abi entry #%d: function must have a name", i)); | ||
| } | ||
| break; | ||
| case Event: | ||
| if (entry.getName().isEmpty() && !entry.getAnonymous()) { | ||
| throw new ContractValidateException(String.format( | ||
| "abi entry #%d: non-anonymous event must have a name", i)); | ||
| } | ||
| break; | ||
| case Error: | ||
| if (entry.getName().isEmpty()) { | ||
| throw new ContractValidateException(String.format( | ||
| "abi entry #%d: error must have a name", i)); | ||
| } | ||
| break; | ||
| default: | ||
| break; | ||
| } | ||
|
|
||
| validateParams(i, "inputs", entry.getInputsList()); | ||
| validateParams(i, "outputs", entry.getOutputsList()); | ||
| } | ||
|
|
||
| if (constructorCount > 1) { | ||
| throw new ContractValidateException("abi: only one constructor is allowed"); | ||
| } | ||
| if (fallbackCount > 1) { | ||
| throw new ContractValidateException("abi: only one fallback function is allowed"); | ||
| } | ||
| if (receiveCount > 1) { | ||
| throw new ContractValidateException("abi: only one receive function is allowed"); | ||
| } | ||
| } | ||
|
|
||
| private static void validateParams(int entryIdx, String side, List<Param> params) | ||
| throws ContractValidateException { | ||
| for (int j = 0; j < params.size(); j++) { | ||
| String type = params.get(j).getType(); | ||
| String reason = checkType(type); | ||
| if (reason != null) { | ||
| throw new ContractValidateException(String.format( | ||
| "abi entry #%d %s[%d] type '%s': %s", entryIdx, side, j, type, reason)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Returns null when the type is acceptable, otherwise a short failure reason. | ||
| private static String checkType(String raw) { | ||
| if (raw == null || raw.isEmpty()) { | ||
| return "type must not be empty"; | ||
| } | ||
| String t = raw.trim(); | ||
|
|
||
| while (true) { | ||
| Matcher m = ARRAY_SUFFIX.matcher(t); | ||
| if (!m.find()) { | ||
| break; | ||
| } | ||
| String n = m.group(1); | ||
| if (!n.isEmpty()) { | ||
| long size; | ||
| try { | ||
| size = Long.parseLong(n); | ||
| } catch (NumberFormatException nfe) { | ||
| return "malformed array size"; | ||
| } | ||
| if (size <= 0) { | ||
| return "array size must be positive"; | ||
| } | ||
| } | ||
| t = t.substring(0, t.length() - m.group().length()); | ||
| } | ||
|
|
||
| if (t.indexOf('[') >= 0 || t.indexOf(']') >= 0) { | ||
| return "malformed array brackets"; | ||
| } | ||
|
|
||
| if (BASE_TYPES.contains(t)) { | ||
| return null; | ||
| } | ||
|
|
||
| Matcher mi = INT_TYPE.matcher(t); | ||
| if (mi.matches()) { | ||
| String width = mi.group(2); | ||
| if (width.isEmpty()) { | ||
| return "shorthand uint/int is not allowed, use uintN/intN"; | ||
| } | ||
| int w; | ||
| try { | ||
| w = Integer.parseInt(width); | ||
| } catch (NumberFormatException nfe) { | ||
| return "invalid integer width"; | ||
| } | ||
| if (w < 8 || w > 256 || (w % 8) != 0) { | ||
| return "integer width must be a multiple of 8 in [8, 256]"; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| Matcher mb = BYTES_N_TYPE.matcher(t); | ||
| if (mb.matches()) { | ||
| int n; | ||
| try { | ||
| n = Integer.parseInt(mb.group(1)); | ||
| } catch (NumberFormatException nfe) { | ||
| return "invalid bytesN size"; | ||
| } | ||
| if (n < 1 || n > 32) { | ||
| return "bytesN size must be in [1, 32]"; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| if (t.startsWith("fixed") || t.startsWith("ufixed")) { | ||
| return "fixed/ufixed types are not supported"; | ||
| } | ||
|
|
||
| return "unknown base type"; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ | |
| import org.tron.core.exception.ReceiptCheckErrException; | ||
| import org.tron.core.exception.VMIllegalException; | ||
| import org.tron.core.store.StoreFactory; | ||
| import org.tron.core.utils.AbiValidator; | ||
| import org.tron.core.vm.repository.Repository; | ||
| import org.tron.core.vm.repository.RepositoryImpl; | ||
|
|
||
|
|
@@ -489,6 +490,8 @@ private static SmartContract.ABI.Entry.EntryType getEntryType(String type) { | |
| return SmartContract.ABI.Entry.EntryType.Event; | ||
| case "fallback": | ||
| return SmartContract.ABI.Entry.EntryType.Fallback; | ||
| case "error": | ||
| return SmartContract.ABI.Entry.EntryType.Error; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great call adding Minor follow-up: both this helper and |
||
| default: | ||
| return SmartContract.ABI.Entry.EntryType.UNRECOGNIZED; | ||
| } | ||
|
|
@@ -603,7 +606,13 @@ public static SmartContract.ABI jsonStr2Abi(String jsonStr) { | |
| abiBuilder.addEntrys(entryBuilder.build()); | ||
| } | ||
|
|
||
| return abiBuilder.build(); | ||
| SmartContract.ABI abi = abiBuilder.build(); | ||
| try { | ||
| AbiValidator.validate(abi); | ||
| } catch (ContractValidateException e) { | ||
| throw new IllegalArgumentException(e.getMessage(), e); | ||
| } | ||
| return abi; | ||
| } | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| package org.tron.core.services.http; | ||
|
|
||
| import static java.nio.charset.StandardCharsets.UTF_8; | ||
| import static org.tron.common.utils.client.utils.HttpMethed.createRequest; | ||
|
|
||
| import javax.annotation.Resource; | ||
| import org.apache.http.client.methods.HttpPost; | ||
| import org.junit.Assert; | ||
| import org.junit.Test; | ||
| import org.springframework.mock.web.MockHttpServletRequest; | ||
| import org.springframework.mock.web.MockHttpServletResponse; | ||
| import org.tron.common.BaseTest; | ||
| import org.tron.common.TestConstants; | ||
| import org.tron.core.config.args.Args; | ||
|
|
||
| public class DeployContractServletTest extends BaseTest { | ||
|
|
||
| static { | ||
| Args.setParam(new String[]{"--output-directory", dbPath()}, TestConstants.TEST_CONF); | ||
| } | ||
|
|
||
| @Resource | ||
| private DeployContractServlet deployContractServlet; | ||
|
|
||
| private static final String OWNER_ADDRESS = "A099357684BC659F5166046B56C95A0E99F1265CBD"; | ||
|
|
||
| private MockHttpServletResponse postWithAbi(String abi) { | ||
| String body = "{" | ||
| + "\"owner_address\":\"" + OWNER_ADDRESS + "\"," | ||
| + "\"name\":\"abi_validation_test\"," | ||
| + "\"bytecode\":\"00\"," | ||
| + "\"abi\":" + abi | ||
| + "}"; | ||
| MockHttpServletRequest request = createRequest(HttpPost.METHOD_NAME); | ||
| request.setContentType("application/json"); | ||
| request.setContent(body.getBytes(UTF_8)); | ||
| MockHttpServletResponse response = new MockHttpServletResponse(); | ||
| deployContractServlet.doPost(request, response); | ||
| return response; | ||
| } | ||
|
|
||
| private static void assertRejected(MockHttpServletResponse response, String snippet) | ||
| throws Exception { | ||
| Assert.assertEquals(200, response.getStatus()); | ||
| String body = response.getContentAsString(); | ||
| Assert.assertTrue("expected error containing '" + snippet + "', got: " + body, | ||
| body.contains("Error") && body.contains(snippet)); | ||
| } | ||
|
|
||
| @Test | ||
| public void rejectsShorthandUint() throws Exception { | ||
| String abi = "[{\"type\":\"function\",\"name\":\"foo\"," | ||
| + "\"inputs\":[{\"name\":\"x\",\"type\":\"uint\"}],\"outputs\":[]}]"; | ||
| assertRejected(postWithAbi(abi), "shorthand uint/int"); | ||
| } | ||
|
|
||
| @Test | ||
| public void rejectsBadBytesN() throws Exception { | ||
| String abi = "[{\"type\":\"function\",\"name\":\"foo\"," | ||
| + "\"inputs\":[{\"name\":\"x\",\"type\":\"bytes33\"}],\"outputs\":[]}]"; | ||
| assertRejected(postWithAbi(abi), "bytesN size"); | ||
| } | ||
|
|
||
| @Test | ||
| public void rejectsDuplicateFallback() throws Exception { | ||
| String abi = "[" | ||
| + "{\"type\":\"fallback\",\"stateMutability\":\"payable\"}," | ||
| + "{\"type\":\"fallback\",\"stateMutability\":\"payable\"}" | ||
| + "]"; | ||
| assertRejected(postWithAbi(abi), "only one fallback"); | ||
| } | ||
|
|
||
| @Test | ||
| public void rejectsNonPayableReceive() throws Exception { | ||
| String abi = "[{\"type\":\"receive\",\"stateMutability\":\"nonpayable\"}]"; | ||
| assertRejected(postWithAbi(abi), "must be payable"); | ||
| } | ||
|
|
||
| @Test | ||
| public void rejectsFixedFamily() throws Exception { | ||
| String abi = "[{\"type\":\"function\",\"name\":\"foo\"," | ||
| + "\"inputs\":[{\"name\":\"x\",\"type\":\"fixed128x18\"}],\"outputs\":[]}]"; | ||
| assertRejected(postWithAbi(abi), "fixed/ufixed"); | ||
| } | ||
|
|
||
| @Test | ||
| public void acceptsTuplePermissively() throws Exception { | ||
| String abi = "[{\"type\":\"function\",\"name\":\"foo\"," | ||
| + "\"inputs\":[{\"name\":\"x\",\"type\":\"tuple\"}," | ||
| + "{\"name\":\"y\",\"type\":\"tuple[]\"}],\"outputs\":[]}]"; | ||
| MockHttpServletResponse response = postWithAbi(abi); | ||
| Assert.assertEquals(200, response.getStatus()); | ||
| String body = response.getContentAsString(); | ||
| Assert.assertFalse("tuple should not be rejected: " + body, body.contains("\"Error\"")); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really nice helper layout —
checkTypereturning eithernullor a short reason makes the call site read very cleanly, and isolating the regex/base-type tables at the top is great for future tweaking. ✨One subtle gap:
t = raw.trim()lets a type like" uint256 "pass validation, but the validator never normalizes the proto — the on-chainParam.typekeeps the original whitespace. Per the linked issue's "fewer inconsistencies in stored ABI metadata" goal, would it make sense to reject whitespace-padded types upfront, e.g.:That way the validator's "this passed" implies "what gets persisted is exactly what got checked", which matches the stated goal of strict-matching tooling compatibility.