Skip to content

Commit

Permalink
HIP 904 add token associate on claim airdrop (0.119) (#9872)
Browse files Browse the repository at this point in the history
HIP 904 add token associate on claim airdrop (#9841)

* Associates a token when a claim airdrop occurs
* Change balance generation transaction timeout from 5m to 10m
* Handle scenario where user manually associates token before claiming

---------

Signed-off-by: Edwin Greene <edwin@swirldslabs.com>
Signed-off-by: Steven Sheehy <steven.sheehy@swirldslabs.com>
Co-authored-by: Edwin Greene <edwin@hashgraph.com>
  • Loading branch information
steven-sheehy and edwin-greene authored Dec 4, 2024
1 parent 71e23ee commit 00412fd
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 11 deletions.
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ value, it is recommended to only populate overridden properties in the custom `a
| `hedera.mirror.importer.parser.record.historicalBalance.initialDelay` | 2m | Initial delay for environments in which the consensus nodes don't produce account balance files. Can accept duration units like `10s`, `2m` etc. |
| `hedera.mirror.importer.parser.record.historicalBalance.minFrequency` | 15m | How often at least to generate balances information. Can accept duration units like `10s`, `2m` etc. The minimum allowed value is `15m`, and the maximum is `7d`. |
| `hedera.mirror.importer.parser.record.historicalBalance.tokenBalances` | true | Whether to generate token balances information. |
| `hedera.mirror.importer.parser.record.historicalBalance.transactionTimeout` | 5m | The timeout in seconds for the database transaction to generate balances information. |
| `hedera.mirror.importer.parser.record.historicalBalance.transactionTimeout` | 10m | The timeout in seconds for the database transaction to generate balances information. |
| `hedera.mirror.importer.parser.record.processingTimeout` | 10s | The additional timeout to allow after the last record stream file health check to verify that files are still being processed. |
| `hedera.mirror.importer.parser.record.pubsub.topicName` | | Pubsub topic to publish transactions to |
| `hedera.mirror.importer.parser.record.pubsub.maxSendAttempts` | 5 | Number of attempts when sending messages to PubSub (only for retryable errors) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import jakarta.persistence.Enumerated;
import jakarta.persistence.IdClass;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Transient;
import java.io.Serial;
import java.io.Serializable;
import lombok.Data;
Expand Down Expand Up @@ -57,6 +58,11 @@ else coalesce(e_{0}, 0) + coalesce({0}, 0)

private Long balanceTimestamp;

@JsonIgnore
@SuppressWarnings("java:S2065")
@Transient
private transient boolean claim;

private Long createdTimestamp;

@Enumerated(EnumType.ORDINAL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import com.hedera.mirror.common.domain.entity.TokenAllowance;
import com.hedera.mirror.common.domain.file.FileData;
import com.hedera.mirror.common.domain.schedule.Schedule;
import com.hedera.mirror.common.domain.token.AbstractTokenAccount.Id;
import com.hedera.mirror.common.domain.token.CustomFee;
import com.hedera.mirror.common.domain.token.Nft;
import com.hedera.mirror.common.domain.token.NftTransfer;
Expand Down Expand Up @@ -65,6 +66,7 @@
import com.hedera.mirror.importer.parser.record.entity.EntityProperties;
import com.hedera.mirror.importer.parser.record.entity.ParserContext;
import com.hedera.mirror.importer.repository.NftRepository;
import com.hedera.mirror.importer.repository.TokenAccountRepository;
import com.hedera.mirror.importer.util.Utility;
import jakarta.inject.Named;
import java.util.Collection;
Expand All @@ -90,6 +92,7 @@ public class SqlEntityListener implements EntityListener, RecordStreamFileListen
private final EntityIdService entityIdService;
private final EntityProperties entityProperties;
private final NftRepository nftRepository;
private final TokenAccountRepository tokenAccountRepository;
private final SqlProperties sqlProperties;

@Override
Expand Down Expand Up @@ -293,7 +296,27 @@ public void onToken(Token token) throws ImporterException {

@Override
public void onTokenAccount(TokenAccount tokenAccount) throws ImporterException {
context.merge(tokenAccount.getId(), tokenAccount, this::mergeTokenAccount);
var id = tokenAccount.getId();

// Users might have already manually associated to this token before claiming the airdrop
if (tokenAccount.isClaim() && isTokenAccountAlreadyAssociated(id)) {
return;
}

context.merge(id, tokenAccount, this::mergeTokenAccount);
}

private boolean isTokenAccountAlreadyAssociated(Id id) {
var existing = context.get(TokenAccount.class, id);

if (existing != null) {
return Objects.requireNonNullElse(existing.getAssociated(), true);
}

return tokenAccountRepository
.findById(id)
.map(TokenAccount::getAssociated)
.orElse(false);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public class HistoricalBalanceProperties {
@DurationMin(seconds = 30)
@DurationUnit(ChronoUnit.SECONDS)
@NotNull
private Duration transactionTimeout = Duration.ofMinutes(5);
private Duration transactionTimeout = Duration.ofMinutes(10);

@PostConstruct
void init() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import com.google.common.collect.Range;
import com.hedera.mirror.common.domain.entity.EntityId;
import com.hedera.mirror.common.domain.token.TokenAccount;
import com.hedera.mirror.common.domain.token.TokenAirdrop;
import com.hedera.mirror.common.domain.token.TokenAirdropStateEnum;
import com.hedera.mirror.common.domain.transaction.RecordItem;
Expand All @@ -43,20 +44,25 @@ abstract class AbstractTokenUpdateAirdropTransactionHandler extends AbstractTran
private final TokenAirdropStateEnum state;
private final TransactionType type;

@Override
public TransactionType getType() {
return type;
}

@Override
public void doUpdateTransaction(Transaction transaction, RecordItem recordItem) {
if (!entityProperties.getPersist().isTokenAirdrops() || !recordItem.isSuccessful()) {
return;
}

var consensusTimestamp = recordItem.getConsensusTimestamp();
var pendingAirdropIds = extractor.apply(recordItem);
for (var pendingAirdropId : pendingAirdropIds) {
var receiver =
entityIdService.lookup(pendingAirdropId.getReceiverId()).orElse(EntityId.EMPTY);
var sender = entityIdService.lookup(pendingAirdropId.getSenderId()).orElse(EntityId.EMPTY);
if (EntityId.isEmpty(receiver) || EntityId.isEmpty(sender)) {
Utility.handleRecoverableError(
"Invalid update token airdrop entity id at {}", recordItem.getConsensusTimestamp());
Utility.handleRecoverableError("Invalid update token airdrop entity id at {}", consensusTimestamp);
continue;
}

Expand All @@ -67,7 +73,7 @@ public void doUpdateTransaction(Transaction transaction, RecordItem recordItem)
tokenAirdrop.setState(state);
tokenAirdrop.setReceiverAccountId(receiver.getId());
tokenAirdrop.setSenderAccountId(sender.getId());
tokenAirdrop.setTimestampRange(Range.atLeast(recordItem.getConsensusTimestamp()));
tokenAirdrop.setTimestampRange(Range.atLeast(consensusTimestamp));

TokenID tokenId;
if (pendingAirdropId.hasFungibleTokenType()) {
Expand All @@ -81,12 +87,26 @@ public void doUpdateTransaction(Transaction transaction, RecordItem recordItem)
var tokenEntityId = EntityId.of(tokenId);
recordItem.addEntityId(tokenEntityId);
tokenAirdrop.setTokenId(tokenEntityId.getId());

if (state == TokenAirdropStateEnum.CLAIMED) {
associateTokenAccount(tokenEntityId, receiver, consensusTimestamp);
}

entityListener.onTokenAirdrop(tokenAirdrop);
}
}

@Override
public TransactionType getType() {
return type;
private void associateTokenAccount(EntityId token, EntityId receiver, long consensusTimestamp) {
var tokenAccount = new TokenAccount();
tokenAccount.setAccountId(receiver.getId());
tokenAccount.setAssociated(true);
tokenAccount.setAutomaticAssociation(false);
tokenAccount.setBalance(0L);
tokenAccount.setBalanceTimestamp(consensusTimestamp);
tokenAccount.setClaim(true);
tokenAccount.setCreatedTimestamp(consensusTimestamp);
tokenAccount.setTimestampLower(consensusTimestamp);
tokenAccount.setTokenId(token.getId());
entityListener.onTokenAccount(tokenAccount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3632,6 +3632,7 @@ void tokenAirdrop(TokenAirdropStateEnum airdropType) {
assertThat(tokenAirdropRepository.findAll())
.containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft));
assertThat(findHistory(TokenAirdrop.class)).isEmpty();
assertThat(tokenAccountRepository.count()).isEqualTo(2);

// when
long updateTimestamp = 30L;
Expand Down Expand Up @@ -3676,6 +3677,16 @@ void tokenAirdrop(TokenAirdropStateEnum airdropType) {
expectedPendingNft.setTimestampRange(Range.atLeast(updateTimestamp));
assertThat(tokenAirdropRepository.findAll())
.containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft));

if (airdropType == TokenAirdropStateEnum.CLAIMED) {
var tokenAccountFungible = tokenAccount(TOKEN_ID, RECEIVER, updateTimestamp);
var tokenAccountNonFungible = tokenAccount(protoNftId.getTokenID(), RECEIVER, updateTimestamp);
assertThat(tokenAccountRepository.findAll())
.hasSize(4)
.contains(tokenAccountFungible, tokenAccountNonFungible);
} else {
assertThat(tokenAccountRepository.count()).isEqualTo(2);
}
}

@ParameterizedTest(name = "{0}")
Expand All @@ -3700,6 +3711,7 @@ void tokenAirdropUpdateState(TokenAirdropStateEnum airdropType) {
.setConsensusTimestamp(TestUtils.toTimestamp(createTimestamp)))
.build();
parseRecordItemAndCommit(tokenCreateRecordItem);
var tokenAccounts = tokenAccountRepository.findAll();

var tokenMintRecordItem = recordItemBuilder
.tokenMint()
Expand Down Expand Up @@ -3795,6 +3807,31 @@ void tokenAirdropUpdateState(TokenAirdropStateEnum airdropType) {
expectedPendingNft.setTimestampRange(Range.atLeast(updateTimestamp));
assertThat(tokenAirdropRepository.findAll())
.containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft));

if (airdropType == TokenAirdropStateEnum.CLAIMED) {
var tokenAccountFungible = tokenAccount(TOKEN_ID, RECEIVER, updateTimestamp);
var tokenAccountNonFungible = tokenAccount(protoNftId.getTokenID(), RECEIVER, updateTimestamp);
assertThat(tokenAccountRepository.findAll())
.hasSize(4)
.containsAnyElementsOf(tokenAccounts)
.contains(tokenAccountFungible, tokenAccountNonFungible);
} else {
assertThat(tokenAccountRepository.findAll()).containsExactlyInAnyOrderElementsOf(tokenAccounts);
}
}

private TokenAccount tokenAccount(TokenID tokenId, AccountID accountId, long consensusTimestamp) {
var tokenAccount = new TokenAccount();
tokenAccount.setAccountId(EntityId.of(accountId).getId());
tokenAccount.setAssociated(true);
tokenAccount.setAutomaticAssociation(false);
tokenAccount.setBalance(0L);
tokenAccount.setBalanceTimestamp(consensusTimestamp);
tokenAccount.setClaim(true);
tokenAccount.setCreatedTimestamp(consensusTimestamp);
tokenAccount.setTimestampLower(consensusTimestamp);
tokenAccount.setTokenId(EntityId.of(tokenId).getId());
return tokenAccount;
}

@ParameterizedTest(name = "{0}")
Expand Down Expand Up @@ -3861,6 +3898,12 @@ void tokenAirdropPartialData(TokenAirdropStateEnum airdropType) {
assertThat(tokenAirdropRepository.findAll())
.containsExactlyInAnyOrderElementsOf(List.of(expectedPendingFungible, expectedPendingNft));
assertThat(findHistory(TokenAirdrop.class)).isEmpty();

if (airdropType == TokenAirdropStateEnum.CLAIMED) {
assertThat(tokenAccountRepository.count()).isEqualTo(2);
} else {
assertThat(tokenAccountRepository.count()).isZero();
}
}

@ParameterizedTest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2399,6 +2399,95 @@ void onTokenAccount() {
assertThat(findHistory(TokenAccount.class)).isEmpty();
}

@Test
void onTokenAccountClaimNoExisting() {
var tokenAccount =
domainBuilder.tokenAccount().customize(ta -> ta.claim(true)).get();

// when
sqlEntityListener.onTokenAccount(tokenAccount);
completeFileAndCommit();

// then
assertThat(tokenAccountRepository.findAll()).containsExactlyInAnyOrder(tokenAccount);
assertThat(findHistory(TokenAccount.class)).isEmpty();
}

@CsvSource(
nullValues = "null",
textBlock = """
false, false
true, false
true, null
""")
@ParameterizedTest
void onTokenAccountClaimNoExistingAssociated(boolean database, Boolean associated) {
var tokenAccountDissociate = domainBuilder
.tokenAccount()
.customize(ta -> ta.associated(associated))
.get();
var tokenAccountClaim = domainBuilder
.tokenAccount()
.customize(ta -> ta.claim(true)
.accountId(tokenAccountDissociate.getAccountId())
.tokenId(tokenAccountDissociate.getTokenId()))
.get();

sqlEntityListener.onTokenAccount(tokenAccountDissociate);

if (database) {
completeFileAndCommit();
}

// when
sqlEntityListener.onTokenAccount(tokenAccountClaim);
completeFileAndCommit();

// then
tokenAccountDissociate.setAssociated(false);
tokenAccountDissociate.setTimestampUpper(tokenAccountClaim.getTimestampLower());
assertThat(tokenAccountRepository.findAll()).containsExactlyInAnyOrder(tokenAccountClaim);
assertThat(findHistory(TokenAccount.class)).containsExactly(tokenAccountDissociate);
}

@CsvSource(
nullValues = "null",
textBlock = """
false, true
false, null
true, true
""")
@ParameterizedTest
void onTokenAccountClaimExistingAssociated(boolean database, Boolean associated) {
var tokenAccountAssociate = domainBuilder
.tokenAccount()
.customize(ta -> ta.associated(associated))
.get();
var tokenAccountClaim = domainBuilder
.tokenAccount()
.customize(ta -> ta.claim(true)
.accountId(tokenAccountAssociate.getAccountId())
.tokenId(tokenAccountAssociate.getTokenId()))
.get();

sqlEntityListener.onTokenAccount(tokenAccountAssociate);

if (database) {
completeFileAndCommit();
}

// when
sqlEntityListener.onTokenAccount(tokenAccountClaim);
completeFileAndCommit();

// then
if (associated == null) { // db defaults to false
tokenAccountAssociate.setAssociated(false);
}
assertThat(tokenAccountRepository.findAll()).containsExactly(tokenAccountAssociate);
assertThat(findHistory(TokenAccount.class)).isEmpty();
}

@Test
void onTokenAccountDissociate() {
EntityId tokenId1 = EntityId.of("0.0.3");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

import com.google.common.collect.Range;
Expand All @@ -38,6 +39,7 @@
import org.mockito.ArgumentCaptor;

class TokenCancelAirdropTransactionHandlerTest extends AbstractTransactionHandlerTest {

private final EntityId receiver = domainBuilder.entityId();
private final AccountID receiverAccountId = recordItemBuilder.accountId();
private final EntityId sender = domainBuilder.entityId();
Expand Down Expand Up @@ -106,5 +108,6 @@ void cancelAirdrop(TokenTypeEnum tokenType) {
.returns(TokenAirdropStateEnum.CANCELLED, TokenAirdrop::getState)
.returns(Range.atLeast(timestamp), TokenAirdrop::getTimestampRange)
.returns(token.getTokenNum(), TokenAirdrop::getTokenId);
verifyNoMoreInteractions(entityListener);
}
}
Loading

0 comments on commit 00412fd

Please sign in to comment.