Skip to content
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

Add gasConsumed cases for Ethereum transactions #8207

Merged
1 change: 1 addition & 0 deletions hedera-mirror-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dependencies {
testImplementation("org.springframework.retry:spring-retry")
testImplementation("org.apache.tuweni:tuweni-bytes")
testImplementation("commons-codec:commons-codec")
testImplementation("org.bouncycastle:bcprov-jdk18on:1.78")
}

// Disable the default test task and only run acceptance tests during the standalone "acceptance"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/*
* Copyright (C) 2024 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.hedera.mirror.test.e2e.acceptance.client;

import com.esaulpaugh.headlong.abi.Tuple;
import com.esaulpaugh.headlong.abi.TupleType;
import com.esaulpaugh.headlong.util.Integers;
import com.hedera.hashgraph.sdk.*;
import com.hedera.mirror.test.e2e.acceptance.response.NetworkTransactionResponse;
import com.hedera.mirror.test.e2e.acceptance.util.ethereum.EthTxData;
import com.hedera.mirror.test.e2e.acceptance.util.ethereum.EthTxSigs;
import jakarta.inject.Named;
import java.math.BigInteger;
import java.util.Collection;
import java.util.HashMap;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
import lombok.RequiredArgsConstructor;
import org.apache.tuweni.bytes.Bytes;
import org.springframework.retry.support.RetryTemplate;

@Named
public class EthereumClient extends AbstractNetworkClient {

private final Collection<ContractId> contractIds = new CopyOnWriteArrayList<>();

private final HashMap<PrivateKey, Integer> accountNonce = new HashMap<>();

public EthereumClient(SDKClient sdkClient, RetryTemplate retryTemplate) {
super(sdkClient, retryTemplate);
}

@Override
public void clean() {
// can't delete ethereum contracts, they are immutable
log.info("Deleting {} contracts", contractIds.size());
}

private final TupleType LONG_TUPLE = TupleType.parse("(int64)");

protected byte[] gasLongToBytes(final Long gas) {
return Bytes.wrap(LONG_TUPLE.encode(Tuple.of(gas)).array()).toArray();
}

public static final BigInteger WEIBARS_TO_TINYBARS = BigInteger.valueOf(10_000_000_000L);
private BigInteger maxFeePerGas = WEIBARS_TO_TINYBARS.multiply(BigInteger.valueOf(50L));
private BigInteger gasPrice = WEIBARS_TO_TINYBARS.multiply(BigInteger.valueOf(50L));

public NetworkTransactionResponse createContract(
PrivateKey signerKey,
FileId fileId,
String fileContents,
long gas,
Hbar payableAmount,
ContractFunctionParameters contractFunctionParameters) {

int nonce = getNonce(signerKey);
byte[] chainId = Integers.toBytes(298);
byte[] maxPriorityGas = gasLongToBytes(20_000L);
byte[] maxGas = gasLongToBytes(maxFeePerGas.longValueExact());
byte[] to = new byte[] {};
BigInteger value = payableAmount != null
? WEIBARS_TO_TINYBARS.multiply(BigInteger.valueOf(payableAmount.toTinybars()))
: BigInteger.ZERO;
// FUTURE - construct bytecode with constructor arguments
byte[] callData = Bytes.fromHexString(fileContents).toArray();

var ethTxData = new EthTxData(
null,
EthTxData.EthTransactionType.EIP1559,
chainId,
nonce,
gasLongToBytes(gasPrice.longValueExact()),
maxPriorityGas,
maxGas,
gas, // gasLimit
to, // to
value, // value
callData,
new byte[] {}, // accessList
0,
null,
null,
null);

var signedEthTxData = EthTxSigs.signMessage(ethTxData, signerKey);
signedEthTxData = signedEthTxData.replaceCallData(new byte[] {});

EthereumTransaction ethereumTransaction = new EthereumTransaction()
.setCallDataFileId(fileId)
.setMaxGasAllowanceHbar(Hbar.from(100L))
.setEthereumData(signedEthTxData.encodeTx());

var memo = getMemo("Create contract");

var response = executeTransactionAndRetrieveReceipt(ethereumTransaction, null, null);
var contractId = response.getReceipt().contractId;
log.info("Created new contract {} with memo '{}' via {}", contractId, memo, response.getTransactionId());

TransactionRecord transactionRecord = getTransactionRecord(response.getTransactionId());
logContractFunctionResult("constructor", transactionRecord.contractFunctionResult);
contractIds.add(contractId);
incrementNonce(signerKey);
return response;
}

public ExecuteContractResult executeContract(
PrivateKey signerKey,
ContractId contractId,
long gas,
String functionName,
ContractFunctionParameters functionParameters,
Hbar payableAmount,
EthTxData.EthTransactionType type) {

int nonce = getNonce(signerKey);
byte[] chainId = Integers.toBytes(298);
byte[] maxPriorityGas = gasLongToBytes(20_000L);
byte[] maxGas = gasLongToBytes(maxFeePerGas.longValueExact());
final var address = contractId.toSolidityAddress();
final var addressBytes = Bytes.fromHexString(address.startsWith("0x") ? address : "0x" + address);
byte[] to = addressBytes.toArray();
var parameters = functionParameters != null ? functionParameters : new ContractFunctionParameters();
byte[] callData = new ContractExecuteTransaction()
.setFunction(functionName, parameters)
.getFunctionParameters()
.toByteArray();

BigInteger value = payableAmount != null ? payableAmount.getValue().toBigInteger() : BigInteger.ZERO;

var ethTxData = new EthTxData(
null,
type,
chainId,
nonce,
gasLongToBytes(gasPrice.longValueExact()),
maxPriorityGas,
maxGas,
gas, // gasLimit
to, // to
value, // value
callData,
new byte[] {}, // accessList
0,
null,
null,
null);

var signedEthTxData = EthTxSigs.signMessage(ethTxData, signerKey);
EthereumTransaction ethereumTransaction = new EthereumTransaction()
.setMaxGasAllowanceHbar(Hbar.from(100L))
.setEthereumData(signedEthTxData.encodeTx());

var response = executeTransactionAndRetrieveReceipt(ethereumTransaction, null, null);

TransactionRecord transactionRecord = getTransactionRecord(response.getTransactionId());
logContractFunctionResult(functionName, transactionRecord.contractFunctionResult);

log.info("Called contract {} function {} via {}", contractId, functionName, response.getTransactionId());
incrementNonce(signerKey);
return new ExecuteContractResult(transactionRecord.contractFunctionResult, response);
}

private void logContractFunctionResult(String functionName, ContractFunctionResult contractFunctionResult) {
if (contractFunctionResult == null) {
return;
}

log.trace(
"ContractFunctionResult for function {}, contractId: {}, gasUsed: {}, logCount: {}",
functionName,
contractFunctionResult.contractId,
contractFunctionResult.gasUsed,
contractFunctionResult.logs.size());
}

@RequiredArgsConstructor
public enum NodeNameEnum {
CONSENSUS("consensus"),
MIRROR("mirror");

private final String name;

static Optional<NodeNameEnum> of(String name) {
try {
return Optional.ofNullable(name).map(NodeNameEnum::valueOf);
} catch (Exception e) {
return Optional.empty();
}
}
}

public String getClientAddress() {
return sdkClient.getClient().getOperatorAccountId().toSolidityAddress();
}

public record ExecuteContractResult(
ContractFunctionResult contractFunctionResult, NetworkTransactionResponse networkTransactionResponse) {}

private Integer getNonce(PrivateKey accountKey) {
return accountNonce.getOrDefault(accountKey, 0);
}

private void incrementNonce(PrivateKey accountKey) {
if (accountNonce.containsKey(accountKey)) {
accountNonce.put(accountKey, accountNonce.get(accountKey) + 1);
} else {
accountNonce.put(accountKey, 1);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,29 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.hedera.mirror.rest.model.ContractAction;
import com.hedera.mirror.rest.model.ContractActionsResponse;
import com.hedera.mirror.rest.model.ContractCallResponse;
import com.hedera.mirror.rest.model.ContractResult;
import com.hedera.mirror.test.e2e.acceptance.client.MirrorNodeClient;
import com.hedera.mirror.test.e2e.acceptance.util.ModelBuilder;
import java.util.List;
import java.util.Optional;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.apache.tuweni.bytes.Bytes;
import org.assertj.core.api.AssertionsForClassTypes;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.client.HttpClientErrorException;

abstract class AbstractEstimateFeature extends AbstractFeature {

private static final int BASE_GAS_FEE = 21_000;
private static final int ADDITIONAL_FEE_FOR_CREATE = 32_000;

protected int lowerDeviation;
protected int upperDeviation;
protected Object gasConsumedSelector;

@Autowired
protected MirrorNodeClient mirrorClient;
Expand Down Expand Up @@ -115,4 +126,66 @@ protected void assertEthCallReturnsBadRequest(String block, String data, String
assertThatThrownBy(() -> mirrorClient.contractsCall(contractCallRequest))
.isInstanceOf(HttpClientErrorException.BadRequest.class);
}

protected void verifyGasConsumed(String txId) {
int totalGasFee;
try {
totalGasFee = calculateIntrinsicValue(gasConsumedSelector);
} catch (DecoderException e) {
throw new RuntimeException("Failed to decode hexadecimal string.", e);
}
var gasConsumed = getGasConsumedByTransactionId(txId);
var gasUsed = getGasFromActions(txId);
AssertionsForClassTypes.assertThat(gasConsumed).isEqualTo(gasUsed + totalGasFee);
}

/**
* Calculates the total intrinsic gas required for a given operation, taking into account the
* operation type and the data involved. This method adjusts the gas calculation based
* on the type of operation: contract creation (CREATE) operations, indicated by a hexadecimal
* string input, include an additional fee on top of the base gas fee. The intrinsic gas for
* the data payload is calculated by adding a specific gas amount for each byte in the payload,
* with different amounts for zero and non-zero bytes.
*
* @param data The operation data, which can be a hexadecimal string for CREATE operations or
* a byte array for contract call operations.
* @return The total intrinsic gas calculated for the operation
* @throws DecoderException If the data parameter is a String and cannot be decoded from hexadecimal
* format, indicating an issue with the input format.
* @throws IllegalArgumentException If the data parameter is not an instance of String or byte[],
* indicating that the provided data type is unsupported for gas
* calculation in the context of this method and tests.
*/
private int calculateIntrinsicValue(Object data) throws DecoderException {
int total = BASE_GAS_FEE;
byte[] values;
if (data instanceof String) {
values = Hex.decodeHex(((String) data).replaceFirst("0x", ""));
total += ADDITIONAL_FEE_FOR_CREATE;
} else if (data instanceof byte[]) {
values = (byte[]) data;
} else {
throw new IllegalArgumentException("Unsupported data type for gas calculation.");
}

// Calculates the intrinsic value by adding 4 for each 0 bytes and 16 for non-zero bytes
for (byte value : values) {
total += (value == 0) ? 4 : 16;
}
return total;
}

private long getGasFromActions(String transactionId) {
return Optional.ofNullable(mirrorClient.getContractResultActionsByTransactionId(transactionId))
.map(ContractActionsResponse::getActions)
.filter(actions -> !actions.isEmpty())
.map(List::getFirst)
.map(ContractAction::getGasUsed)
.orElse(0L); // Provide a default value in case any step results in null
}

private Long getGasConsumedByTransactionId(String transactionId) {
ContractResult contractResult = mirrorClient.getContractResultByTransactionId(transactionId);
return contractResult.getGasConsumed();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
import com.hedera.mirror.rest.model.NetworkExchangeRateSetResponse;
import com.hedera.mirror.rest.model.TransactionByIdResponse;
import com.hedera.mirror.rest.model.TransactionDetail;
import com.hedera.mirror.test.e2e.acceptance.client.AccountClient;
import com.hedera.mirror.test.e2e.acceptance.client.ContractClient;
import com.hedera.mirror.test.e2e.acceptance.client.ContractClient.NodeNameEnum;
import com.hedera.mirror.test.e2e.acceptance.client.EncoderDecoderFacade;
import com.hedera.mirror.test.e2e.acceptance.client.EthereumClient;
import com.hedera.mirror.test.e2e.acceptance.client.FileClient;
import com.hedera.mirror.test.e2e.acceptance.client.MirrorNodeClient;
import com.hedera.mirror.test.e2e.acceptance.client.NetworkAdapter;
Expand Down Expand Up @@ -61,6 +63,12 @@ public abstract class AbstractFeature extends EncoderDecoderFacade {
@Autowired
protected ContractClient contractClient;

@Autowired
protected EthereumClient ethereumClient;

@Autowired
protected AccountClient accountClient;

@Autowired
protected FileClient fileClient;

Expand Down
Loading