@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
342 lines (304 loc) • 13.1 kB
text/typescript
import { encodeAccountId, getSyncHash } from "@ledgerhq/ledger-wallet-framework/account";
import type {
GetAccountShape,
IterateResultBuilder,
} from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers";
import { mergeOps } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers";
import type { Result } from "@ledgerhq/ledger-wallet-framework/derivation";
import {
getDerivationScheme,
runDerivationScheme,
} from "@ledgerhq/ledger-wallet-framework/derivation";
import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
import type { Account, Operation } from "@ledgerhq/types-live";
import { BigNumber } from "bignumber.js";
import invariant from "invariant";
import hederaCoinConfig from "../config";
import { HARDCODED_BLOCK_HEIGHT } from "../constants";
import { listOperations, listOperationsV2 } from "../logic";
import { toEVMAddress } from "../logic/utils";
import { apiClient } from "../network/api";
import { thirdwebClient } from "../network/thirdweb";
import { getERC20BalancesForAccount, getERC20BalancesForAccountV2 } from "../network/utils";
import type { HederaAccount } from "../types";
import {
getSubAccounts,
prepareOperations,
applyPendingExtras,
mergeSubAccounts,
integrateERC20Operations,
} from "./utils";
const getAccountShapeV2 = async ({
address,
evmAddress,
liveAccountId,
currency,
initialAccount,
blacklistedTokenIds,
}: {
address: string;
evmAddress: string;
liveAccountId: string;
currency: CryptoCurrency;
initialAccount: HederaAccount | undefined;
blacklistedTokenIds: string[] | undefined;
}): Promise<Partial<HederaAccount>> => {
// get current account balance and tokens
// tokens are fetched with separate requests to get "created_timestamp" for each token
// based on this, ASSOCIATE_TOKEN operations can be connected with tokens
const [mirrorAccount, mirrorTokens, erc20Tokens] = await Promise.all([
apiClient.getAccount(address),
apiClient.getAccountTokens(address),
getERC20BalancesForAccountV2(address),
]);
const accountBalance = new BigNumber(mirrorAccount.balance.balance);
// we should sync again when new tokens are added or blacklist changes
const syncHash = await getSyncHash(currency.id, blacklistedTokenIds);
const shouldSyncFromScratch = !initialAccount || syncHash !== initialAccount?.syncHash;
const pendingOperations = shouldSyncFromScratch ? [] : (initialAccount?.pendingOperations ?? []);
const oldOperations = shouldSyncFromScratch ? [] : (initialAccount?.operations ?? []);
const latestOperation = oldOperations[0];
// grab latest operation timestamps for incremental sync
let latestOperationTimestamp: string | null = null;
if (!shouldSyncFromScratch && latestOperation) {
const timestamp = Math.floor(latestOperation.date.getTime() / 1000);
latestOperationTimestamp = new BigNumber(timestamp).toFixed(9);
}
const latestAccountOperations = await listOperationsV2({
currency,
address,
evmAddress,
mirrorTokens,
erc20Tokens,
...(latestOperationTimestamp && { cursor: latestOperationTimestamp }),
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: true,
useSyntheticBlocks: false,
});
const newOperations = await prepareOperations(
latestAccountOperations.coinOperations,
latestAccountOperations.tokenOperations,
);
const enrichedNewOperations = applyPendingExtras(newOperations, pendingOperations);
const operations = shouldSyncFromScratch
? enrichedNewOperations
: mergeOps(oldOperations, enrichedNewOperations);
const delegation =
typeof mirrorAccount.staked_node_id === "number"
? {
nodeId: mirrorAccount.staked_node_id,
delegated: accountBalance,
pendingReward: new BigNumber(mirrorAccount.pending_reward),
}
: null;
const newSubAccounts = await getSubAccounts({
ledgerAccountId: liveAccountId,
latestTokenOperations: latestAccountOperations.tokenOperations,
mirrorTokens,
erc20Tokens,
});
const subAccounts = shouldSyncFromScratch
? newSubAccounts
: mergeSubAccounts(initialAccount, newSubAccounts);
return {
id: liveAccountId,
freshAddress: address,
syncHash,
lastSyncDate: new Date(),
balance: accountBalance,
spendableBalance: accountBalance,
operations: operations,
operationsCount: operations.length,
// NOTE: there are no "blocks" in hedera
// set a value just so that operations are considered confirmed according to isConfirmedOperation
blockHeight: HARDCODED_BLOCK_HEIGHT,
subAccounts,
hederaResources: {
maxAutomaticTokenAssociations: mirrorAccount.max_automatic_token_associations,
isAutoTokenAssociationEnabled: mirrorAccount.max_automatic_token_associations === -1,
delegation,
},
};
};
export const getAccountShape: GetAccountShape<HederaAccount> = async (
info,
{ blacklistedTokenIds },
): Promise<Partial<HederaAccount>> => {
const { currency, derivationMode, address, initialAccount } = info;
invariant(address, "hedera: address is expected");
const evmAddress = await toEVMAddress(address);
invariant(evmAddress, `hedera: evm address is missing for ${address}`);
const coinConfig = hederaCoinConfig.getCoinConfig(currency.id);
const liveAccountId = encodeAccountId({
type: "js",
version: "2",
currencyId: currency.id,
xpubOrAddress: address,
derivationMode,
});
if (coinConfig.useHgraphForErc20) {
return getAccountShapeV2({
address,
evmAddress,
liveAccountId,
currency,
initialAccount,
blacklistedTokenIds,
});
}
// get current account balance and tokens
// tokens are fetched with separate requests to get "created_timestamp" for each token
// based on this, ASSOCIATE_TOKEN operations can be connected with tokens
const [mirrorAccount, mirrorTokens, erc20TokenBalances] = await Promise.all([
apiClient.getAccount(address),
apiClient.getAccountTokens(address),
getERC20BalancesForAccount(evmAddress),
]);
const accountBalance = new BigNumber(mirrorAccount.balance.balance);
// we should sync again when new tokens are added or blacklist changes
const syncHash = await getSyncHash(currency.id, blacklistedTokenIds);
const shouldSyncFromScratch = !initialAccount || syncHash !== initialAccount?.syncHash;
const pendingOperations = shouldSyncFromScratch ? [] : (initialAccount?.pendingOperations ?? []);
const oldOperations = shouldSyncFromScratch ? [] : (initialAccount?.operations ?? []);
const oldERC20Operations = oldOperations.filter(item => item.standard === "erc20");
const latestOperation = oldOperations[0];
const erc20LatestOperation = oldERC20Operations[0];
const pendingOperationHashes = new Set(pendingOperations.map(op => op.hash));
const erc20OperationHashes = new Set(oldERC20Operations.map(op => op.hash));
// grab latest operation timestamps for incremental sync
let latestOperationTimestamp: string | null = null;
let erc20LatestOperationTimestamp: string | null = null;
if (!shouldSyncFromScratch && latestOperation) {
const timestamp = Math.floor(latestOperation.date.getTime() / 1000);
latestOperationTimestamp = new BigNumber(timestamp).toString();
}
if (!shouldSyncFromScratch && erc20LatestOperation) {
const timestamp = Math.floor(erc20LatestOperation.date.getTime() / 1000);
erc20LatestOperationTimestamp = new BigNumber(timestamp).toString();
}
const [latestAccountOperations, erc20Transactions] = await Promise.all([
listOperations({
currency,
address,
mirrorTokens,
cursor: latestOperationTimestamp?.toString(),
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: true,
useSyntheticBlocks: false,
}),
thirdwebClient.getERC20TransactionsForAccount({
address,
contractAddresses: erc20TokenBalances.map(token => token.token.contractAddress),
since: erc20LatestOperationTimestamp,
}),
]);
const newOperations = await prepareOperations(
latestAccountOperations.coinOperations,
latestAccountOperations.tokenOperations,
);
const enrichedNewOperations = applyPendingExtras(newOperations, pendingOperations);
const operations = shouldSyncFromScratch
? enrichedNewOperations
: mergeOps(oldOperations, enrichedNewOperations);
const delegation =
typeof mirrorAccount.staked_node_id === "number"
? {
nodeId: mirrorAccount.staked_node_id,
delegated: accountBalance,
pendingReward: new BigNumber(mirrorAccount.pending_reward),
}
: null;
// how ERC20 operations are handled:
// - mirror node doesn't include "IN" erc20 token transactions
// - mirror node returns "CONTRACT_CALL" (OUT) erc20 token transactions made from given account address
// - mirror node doesn't return "CONTRACT_CALL" (OUT) erc20 token transactions made from 3rd party with allowance
//
// 1. mirror node transactions are already transformed into operations at this point + we have raw erc20 transactions fetched from thirdweb
// 2. related mirror node transaction must be fetched for each erc20 transaction (to get fee and consensus timestamp)
// 3. CONTRACTCALL operations must be removed if existing operations already include erc20 operation with the same tx.hash
// 4. ERC20 transactions must be classified into two groups: patchList and addList
// - patchList: transactions which are already present in mirror operations (we can have CONTRACT_CALL from mirror node that we can transform into "FEES")
// - addList should include transactions which are missing in mirror operations (e.g. "IN" erc20 token transaction and "OUT" made by 3rd party with allowance)
// 5. list of all operations must be updated based on prepared `patchList` and `addList`
// 6. sub accounts must get erc20 tokens and erc20 operations in addition to hts tokens and hts operations
// 7. postSync must remove pending operations that are already confirmed as erc20 operations
const { updatedOperations, newERC20TokenOperations } = await integrateERC20Operations({
ledgerAccountId: liveAccountId,
address,
allOperations: operations,
latestERC20Transactions: erc20Transactions,
pendingOperationHashes,
erc20OperationHashes,
});
const newSubAccounts = await getSubAccounts({
ledgerAccountId: liveAccountId,
latestTokenOperations: [...latestAccountOperations.tokenOperations, ...newERC20TokenOperations],
mirrorTokens,
erc20Tokens: erc20TokenBalances,
});
const subAccounts = shouldSyncFromScratch
? newSubAccounts
: mergeSubAccounts(initialAccount, newSubAccounts);
return {
id: liveAccountId,
freshAddress: address,
syncHash,
lastSyncDate: new Date(),
balance: accountBalance,
spendableBalance: accountBalance,
operations: updatedOperations,
operationsCount: updatedOperations.length,
// NOTE: there are no "blocks" in hedera
// Set a value just so that operations are considered confirmed according to isConfirmedOperation
blockHeight: HARDCODED_BLOCK_HEIGHT,
subAccounts,
hederaResources: {
maxAutomaticTokenAssociations: mirrorAccount.max_automatic_token_associations,
isAutoTokenAssociationEnabled: mirrorAccount.max_automatic_token_associations === -1,
delegation,
},
};
};
export const buildIterateResult: IterateResultBuilder = async ({ result: rootResult }) => {
const mirrorAccounts = await apiClient.getAccountsForPublicKey(rootResult.publicKey);
const addresses = mirrorAccounts.map(a => a.account);
return async ({ currency, derivationMode, index }) => {
const derivationScheme = getDerivationScheme({
derivationMode,
currency,
});
const freshAddressPath = runDerivationScheme(derivationScheme, currency, {
account: index,
});
return addresses[index]
? ({
address: addresses[index],
publicKey: addresses[index],
path: freshAddressPath,
} satisfies Result)
: null;
};
};
// TODO: remove once migration to new API is complete
// it might be necessary to remove pending operations after ERC20 patching done by `integrateERC20Operations`
export const postSync = (_initial: Account, synced: Account): Account => {
const coinConfig = hederaCoinConfig.getCoinConfig(synced.currency.id);
if (coinConfig.useHgraphForErc20) {
return synced;
}
const erc20Operations = synced.operations.filter(op => op.standard === "erc20");
const erc20Hashes = new Set(erc20Operations.map(op => op.hash));
const excludeConfirmedERC20Operations = (o: Operation) => !erc20Hashes.has(o.hash);
return {
...synced,
pendingOperations: synced.pendingOperations.filter(excludeConfirmedERC20Operations),
subAccounts: (synced.subAccounts ?? []).map(subAccount => {
return {
...subAccount,
pendingOperations: subAccount.pendingOperations.filter(excludeConfirmedERC20Operations),
};
}),
};
};