Skip to content

Commit d0e1eb9

Browse files
fix: (0.70) Ensure HTS transfer gas cost reflects HBAR auto-creations (#23180)
Signed-off-by: Michael Tinker <michael.tinker@swirldslabs.com> Co-authored-by: Andrew Brandt <andrew.brandt@hashgraph.com>
1 parent cb31575 commit d0e1eb9

4 files changed

Lines changed: 238 additions & 2 deletions

File tree

hedera-node/hedera-smart-contract-service-impl/src/main/java/com/hedera/node/app/service/contract/impl/exec/systemcontracts/hts/transfer/ClassicTransfersCall.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import static com.hedera.node.app.service.contract.impl.exec.utils.FrameUtils.configOf;
1616
import static java.util.Objects.requireNonNull;
1717

18+
import com.google.common.annotations.VisibleForTesting;
1819
import com.hedera.hapi.node.base.AccountID;
1920
import com.hedera.hapi.node.base.ResponseCodeEnum;
2021
import com.hedera.hapi.node.base.TransferList;
@@ -215,7 +216,8 @@ public static long transferGasRequirement(
215216
return systemContractGasCalculator.gasRequirementWithTinycents(body, payerId, minimumTinycentPrice);
216217
}
217218

218-
private static long minimumTinycentPriceGiven(
219+
@VisibleForTesting
220+
public static long minimumTinycentPriceGiven(
219221
@NonNull final CryptoTransferTransactionBody op,
220222
final long baseUnitAdjustTinyCentPrice,
221223
final long baseAdjustTinyCentsPrice,
@@ -256,6 +258,17 @@ private static long minimumTinycentPriceGiven(
256258
}
257259
}
258260
}
261+
for (final var adjust : op.transfersOrElse(TransferList.DEFAULT).accountAmounts()) {
262+
if (adjust.amount() > 0 && adjust.accountIDOrElse(AccountID.DEFAULT).hasAlias()) {
263+
final var accountId = adjust.accountIDOrThrow();
264+
final var alias = accountId.aliasOrThrow();
265+
final var extantAccount =
266+
extantAccounts.getAccountIDByAlias(accountId.shardNum(), accountId.realmNum(), alias);
267+
if (extantAccount == null) {
268+
aliasesToLazyCreate.add(alias);
269+
}
270+
}
271+
}
259272
minimumTinycentPrice += aliasesToLazyCreate.size() * baseLazyCreationPrice;
260273
return minimumTinycentPrice;
261274
}

hedera-node/hedera-smart-contract-service-impl/src/test/java/com/hedera/node/app/service/contract/impl/test/exec/systemcontracts/hts/transfer/ClassicTransfersCallTest.java

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222

2323
import com.esaulpaugh.headlong.abi.Tuple;
2424
import com.esaulpaugh.headlong.abi.TupleType;
25+
import com.hedera.hapi.node.base.AccountAmount;
26+
import com.hedera.hapi.node.base.AccountID;
2527
import com.hedera.hapi.node.base.Key;
28+
import com.hedera.hapi.node.base.TransferList;
2629
import com.hedera.hapi.node.token.CryptoTransferTransactionBody;
2730
import com.hedera.hapi.node.transaction.TransactionBody;
2831
import com.hedera.node.app.service.contract.impl.exec.failure.CustomExceptionalHaltReason;
@@ -37,7 +40,9 @@
3740
import com.hedera.node.app.service.contract.impl.exec.systemcontracts.hts.transfer.SystemAccountCreditScreen;
3841
import com.hedera.node.app.service.contract.impl.records.ContractCallStreamBuilder;
3942
import com.hedera.node.app.service.contract.impl.test.exec.systemcontracts.common.CallTestBase;
43+
import com.hedera.node.app.service.token.ReadableAccountStore;
4044
import com.hedera.node.config.testfixtures.HederaTestConfigBuilder;
45+
import com.hedera.pbj.runtime.io.buffer.Bytes;
4146
import java.util.Optional;
4247
import java.util.function.Predicate;
4348
import org.hyperledger.besu.evm.frame.MessageFrame;
@@ -71,6 +76,9 @@ class ClassicTransfersCallTest extends CallTestBase {
7176
@Mock
7277
private SpecialRewardReceivers specialRewardReceivers;
7378

79+
@Mock
80+
private ReadableAccountStore readableAccountStore;
81+
7482
private ClassicTransfersCall subject;
7583

7684
@Test
@@ -209,6 +217,95 @@ void supportedV2transferCompletesWithNominalResponseCode() {
209217
result.getOutput());
210218
}
211219

220+
@Test
221+
void gasRequirementReflectsHbarAutoCreations() {
222+
final var shardNum = 0L;
223+
final var realmNum = 0L;
224+
225+
final var aliasToCreate = Bytes.wrap("alias-to-create".getBytes());
226+
final var existingAlias = Bytes.wrap("existing-alias".getBytes());
227+
228+
final var autoCreatedAccountId = AccountID.newBuilder()
229+
.shardNum(shardNum)
230+
.realmNum(realmNum)
231+
.alias(aliasToCreate)
232+
.build();
233+
234+
final var existingAccountWithAlias = AccountID.newBuilder()
235+
.shardNum(shardNum)
236+
.realmNum(realmNum)
237+
.alias(existingAlias)
238+
.build();
239+
240+
final var payerAccountId = AccountID.newBuilder()
241+
.shardNum(shardNum)
242+
.realmNum(realmNum)
243+
.accountNum(1L)
244+
.build();
245+
246+
// One positive HBAR transfer to a new alias (triggers lazy creation)
247+
final var creditNewAlias = AccountAmount.newBuilder()
248+
.accountID(autoCreatedAccountId)
249+
.amount(10L)
250+
.build();
251+
252+
// Another positive HBAR transfer to the same alias; should not increase lazy-creation count
253+
final var anotherCreditSameAlias = AccountAmount.newBuilder()
254+
.accountID(autoCreatedAccountId)
255+
.amount(20L)
256+
.build();
257+
258+
// Positive HBAR transfer to an existing alias (no lazy creation)
259+
final var creditExistingAlias = AccountAmount.newBuilder()
260+
.accountID(existingAccountWithAlias)
261+
.amount(30L)
262+
.build();
263+
264+
// Positive HBAR transfer to a non-aliased account (payer); condition true but no alias
265+
final var creditPayerNoAlias =
266+
AccountAmount.newBuilder().accountID(payerAccountId).amount(5L).build();
267+
268+
// Negative HBAR transfer from a non-aliased account (payer); not considered for lazy creation
269+
final var debitPayer = AccountAmount.newBuilder()
270+
.accountID(payerAccountId)
271+
.amount(-65L)
272+
.build();
273+
274+
final var transferList = TransferList.newBuilder()
275+
.accountAmounts(
276+
creditNewAlias, anotherCreditSameAlias, creditExistingAlias, creditPayerNoAlias, debitPayer)
277+
.build();
278+
279+
final var op = CryptoTransferTransactionBody.newBuilder()
280+
.transfers(transferList)
281+
.build();
282+
283+
// Only the aliasToCreate is missing, existingAlias is already mapped
284+
given(readableAccountStore.getAccountIDByAlias(shardNum, realmNum, aliasToCreate))
285+
.willReturn(null);
286+
given(readableAccountStore.getAccountIDByAlias(shardNum, realmNum, existingAlias))
287+
.willReturn(existingAccountWithAlias);
288+
289+
final long baseUnitAdjustTinyCentPrice = 0L; // No token transfers in this test
290+
final long baseAdjustTinyCentsPrice = 10L;
291+
final long baseNftTransferTinyCentsPrice = 0L; // No NFT transfers in this test
292+
final long baseLazyCreationPrice = 1_000L;
293+
294+
final long result = ClassicTransfersCall.minimumTinycentPriceGiven(
295+
op,
296+
baseUnitAdjustTinyCentPrice,
297+
baseAdjustTinyCentsPrice,
298+
baseNftTransferTinyCentsPrice,
299+
baseLazyCreationPrice,
300+
readableAccountStore);
301+
302+
final long numTinyCentsAdjusts = 5L; // five AccountAmount entries in the HBAR TransferList
303+
final long expected = numTinyCentsAdjusts * baseAdjustTinyCentsPrice
304+
+ baseLazyCreationPrice; // exactly one distinct missing alias
305+
306+
assertEquals(expected, result);
307+
}
308+
212309
private static final TransactionBody PRETEND_TRANSFER = TransactionBody.newBuilder()
213310
.cryptoTransfer(CryptoTransferTransactionBody.DEFAULT)
214311
.build();

hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/utilops/UtilVerbs.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2767,7 +2767,16 @@ public static Tuple accountAmount(final Address accountAddress, final Long amoun
27672767
}
27682768

27692769
public static Tuple accountAmountAlias(final byte[] alias, final Long amount) {
2770-
return Tuple.of(HapiParserUtil.asHeadlongAddress(alias), amount);
2770+
return Tuple.of(HapiParserUtil.asHeadlongAddress(alias), amount, false);
2771+
}
2772+
2773+
public static Tuple nftTransferToAlias(
2774+
@NonNull final AccountID sender, @NonNull final byte[] alias, final long serialNumber) {
2775+
return Tuple.of(
2776+
HapiParserUtil.asHeadlongAddress(asAddress(sender)),
2777+
HapiParserUtil.asHeadlongAddress(alias),
2778+
serialNumber,
2779+
false);
27712780
}
27722781

27732782
public static Tuple accountAmountAlias(final byte[] alias, final Long amount, final boolean isApproval) {

hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/contract/precompile/AtomicCryptoTransferHTSSuite.java

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import static com.hedera.services.bdd.spec.assertions.ContractFnResultAsserts.resultWith;
99
import static com.hedera.services.bdd.spec.assertions.TransactionRecordAsserts.recordWith;
1010
import static com.hedera.services.bdd.spec.assertions.TransferListAsserts.including;
11+
import static com.hedera.services.bdd.spec.dsl.entities.SpecTokenKey.SUPPLY_KEY;
1112
import static com.hedera.services.bdd.spec.keys.KeyShape.DELEGATE_CONTRACT;
1213
import static com.hedera.services.bdd.spec.keys.KeyShape.sigs;
1314
import static com.hedera.services.bdd.spec.keys.SigControl.ON;
@@ -33,13 +34,20 @@
3334
import static com.hedera.services.bdd.spec.transactions.token.CustomFeeSpecs.fractionalFee;
3435
import static com.hedera.services.bdd.spec.transactions.token.CustomFeeSpecs.royaltyFeeWithFallback;
3536
import static com.hedera.services.bdd.spec.transactions.token.TokenMovement.moving;
37+
import static com.hedera.services.bdd.spec.transactions.token.TokenMovement.movingHbar;
38+
import static com.hedera.services.bdd.spec.transactions.token.TokenMovement.movingUnique;
3639
import static com.hedera.services.bdd.spec.utilops.CustomSpecAssert.allRunFor;
3740
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.accountAmount;
41+
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.accountAmountAlias;
42+
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.assertCloseEnough;
43+
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.assertionsHold;
3844
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.childRecordsCheck;
3945
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.newKeyNamed;
4046
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.nftTransfer;
47+
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.nftTransferToAlias;
4148
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.overriding;
4249
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sourcing;
50+
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.sourcingContextual;
4351
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.tokenTransferList;
4452
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.tokenTransferLists;
4553
import static com.hedera.services.bdd.spec.utilops.UtilVerbs.transferList;
@@ -50,6 +58,7 @@
5058
import static com.hedera.services.bdd.suites.HapiSuite.ONE_HBAR;
5159
import static com.hedera.services.bdd.suites.HapiSuite.ONE_HUNDRED_HBARS;
5260
import static com.hedera.services.bdd.suites.contract.Utils.asHexedSolidityAddress;
61+
import static com.hedera.services.bdd.suites.utils.MiscEETUtils.genRandomBytes;
5362
import static com.hedera.services.bdd.suites.utils.MiscEETUtils.metadata;
5463
import static com.hedera.services.bdd.suites.utils.contracts.precompile.HTSPrecompileResult.htsPrecompileResult;
5564
import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.AMOUNT_EXCEEDS_ALLOWANCE;
@@ -63,10 +72,17 @@
6372
import com.esaulpaugh.headlong.abi.Tuple;
6473
import com.hedera.node.app.hapi.utils.ByteStringUtils;
6574
import com.hedera.services.bdd.junit.HapiTest;
75+
import com.hedera.services.bdd.junit.HapiTestLifecycle;
6676
import com.hedera.services.bdd.junit.LeakyHapiTest;
6777
import com.hedera.services.bdd.spec.assertions.ContractInfoAsserts;
6878
import com.hedera.services.bdd.spec.assertions.NonFungibleTransfers;
6979
import com.hedera.services.bdd.spec.assertions.SomeFungibleTransfers;
80+
import com.hedera.services.bdd.spec.dsl.annotations.Contract;
81+
import com.hedera.services.bdd.spec.dsl.annotations.FungibleToken;
82+
import com.hedera.services.bdd.spec.dsl.annotations.NonFungibleToken;
83+
import com.hedera.services.bdd.spec.dsl.entities.SpecContract;
84+
import com.hedera.services.bdd.spec.dsl.entities.SpecFungibleToken;
85+
import com.hedera.services.bdd.spec.dsl.entities.SpecNonFungibleToken;
7086
import com.hedera.services.bdd.spec.keys.KeyShape;
7187
import com.hedera.services.bdd.spec.transactions.token.TokenMovement;
7288
import com.hederahashgraph.api.proto.java.TokenSupplyType;
@@ -80,6 +96,7 @@
8096
import org.junit.jupiter.api.Tag;
8197

8298
@Tag(SMART_CONTRACT)
99+
@HapiTestLifecycle
83100
public class AtomicCryptoTransferHTSSuite {
84101
private static final long GAS_FOR_AUTO_ASSOCIATING_CALLS = 2_000_000;
85102
private static final Tuple[] EMPTY_TUPLE_ARRAY = new Tuple[] {};
@@ -104,6 +121,106 @@ public class AtomicCryptoTransferHTSSuite {
104121

105122
public static final String SECP_256K1_SOURCE_KEY = "secp256k1Alias";
106123

124+
@HapiTest
125+
final Stream<DynamicTest> hollowAccountCreationFeesAsExpected(
126+
@Contract(creationGas = 2_000_000, contract = "AtomicCryptoTransfer", maxAutoAssociations = 2)
127+
SpecContract contract,
128+
@FungibleToken SpecFungibleToken fungibleToken,
129+
@NonFungibleToken(
130+
keys = {SUPPLY_KEY},
131+
numPreMints = 1)
132+
SpecNonFungibleToken nonFungibleToken) {
133+
final AtomicLong hbarAutoCreationGas = new AtomicLong();
134+
final AtomicLong ftAutoCreationGas = new AtomicLong();
135+
final AtomicLong nftAutoCreationGas = new AtomicLong();
136+
return hapiTest(
137+
contract.getInfo(),
138+
fungibleToken.getInfo(),
139+
nonFungibleToken.getInfo(),
140+
// Give the contract assets to use in creating hollow accounts
141+
cryptoTransfer(
142+
movingHbar(10 * ONE_HUNDRED_HBARS).between(GENESIS, contract.name()),
143+
moving(1, fungibleToken.name())
144+
.between(fungibleToken.treasury().name(), contract.name()),
145+
movingUnique(nonFungibleToken.name(), 1)
146+
.between(nonFungibleToken.treasury().name(), contract.name()))
147+
.signedBy(
148+
GENESIS,
149+
fungibleToken.treasury().name(),
150+
nonFungibleToken.treasury().name()),
151+
// First auto-create a hollow account with HBAR
152+
sourcingContextual(spec -> contractCall(
153+
contract.name(),
154+
"transferMultipleTokens",
155+
transferList()
156+
.withAccountAmounts(
157+
accountAmount(
158+
spec.registry().getAccountID(contract.name()), -1L, false),
159+
accountAmountAlias(genRandomBytes(20), +1L))
160+
.build(),
161+
EMPTY_TUPLE_ARRAY)
162+
.payingWith(GENESIS)
163+
.via("hbarAutoCreation")
164+
.gas(2_000_000L)),
165+
getTxnRecord("hbarAutoCreation")
166+
.exposingTo(r -> hbarAutoCreationGas.set(
167+
r.getContractCallResult().getGasUsed())),
168+
// Then auto-create a hollow account with a fungible token
169+
sourcingContextual(spec -> contractCall(
170+
contract.name(),
171+
"transferMultipleTokens",
172+
transferList()
173+
.withAccountAmounts(EMPTY_TUPLE_ARRAY)
174+
.build(),
175+
wrapIntoTupleArray(tokenTransferList()
176+
.forToken(spec.registry().getTokenID(fungibleToken.name()))
177+
.withAccountAmounts(
178+
accountAmount(
179+
spec.registry().getAccountID(contract.name()), -1L, false),
180+
accountAmountAlias(genRandomBytes(20), +1L))
181+
.build()))
182+
.payingWith(GENESIS)
183+
.via("ftAutoCreation")
184+
.gas(2_000_000L)),
185+
getTxnRecord("ftAutoCreation")
186+
.exposingTo(r ->
187+
ftAutoCreationGas.set(r.getContractCallResult().getGasUsed())),
188+
// And finally auto-create a hollow account with a non-fungible token
189+
sourcingContextual(spec -> contractCall(
190+
contract.name(),
191+
"transferMultipleTokens",
192+
transferList()
193+
.withAccountAmounts(EMPTY_TUPLE_ARRAY)
194+
.build(),
195+
wrapIntoTupleArray(tokenTransferList()
196+
.forToken(spec.registry().getTokenID(nonFungibleToken.name()))
197+
.withNftTransfers(nftTransferToAlias(
198+
spec.registry().getAccountID(contract.name()), genRandomBytes(20), 1L))
199+
.build()))
200+
.payingWith(GENESIS)
201+
.via("nftAutoCreation")
202+
.gas(2_000_000L)),
203+
getTxnRecord("nftAutoCreation")
204+
.exposingTo(r ->
205+
nftAutoCreationGas.set(r.getContractCallResult().getGasUsed())),
206+
assertionsHold((spec, opLog) -> {
207+
// The HTS auto-creations should be almost exactly the same
208+
assertCloseEnough(
209+
1.0,
210+
1.0 * ftAutoCreationGas.get() / nftAutoCreationGas.get(),
211+
1.0,
212+
"ratio of FT to NFT auto-creation gas",
213+
"via IHederaTokenService");
214+
// The HBAR auto-creation should be ~1/2 of them, since it doesn't require an auto-association
215+
assertCloseEnough(
216+
0.5,
217+
1.0 * hbarAutoCreationGas.get() / ftAutoCreationGas.get(),
218+
5.0,
219+
"ratio of HBAR to FT auto-creation gas",
220+
"via IHederaTokenService");
221+
}));
222+
}
223+
107224
@HapiTest
108225
final Stream<DynamicTest> cryptoTransferForHbarOnly() {
109226
final var cryptoTransferTxn = "cryptoTransferTxn";

0 commit comments

Comments
 (0)