@ledgerhq/coin-aptos
Version:
Ledger Aptos Coin integration
419 lines (368 loc) • 12.6 kB
text/typescript
import { ApolloClient, InMemoryCache } from "@apollo/client";
import {
type AccountData,
Aptos,
AptosConfig,
Ed25519PublicKey,
type GasEstimation,
type InputEntryFunctionData,
type InputGenerateTransactionOptions,
MimeType,
MoveStructId,
type RawTransaction,
type SimpleTransaction,
type TransactionResponse,
type UserTransactionResponse,
type Block,
type AptosSettings,
Hex,
postAptosFullNode,
type PendingTransactionResponse,
Network,
} from "@aptos-labs/ts-sdk";
import { getEnv } from "@ledgerhq/live-env";
import network from "@ledgerhq/live-network";
import BigNumber from "bignumber.js";
import isUndefined from "lodash/isUndefined";
import {
APTOS_ASSET_ID,
DEFAULT_GAS,
DEFAULT_GAS_PRICE,
ESTIMATE_GAS_MUL,
TOKEN_TYPE,
} from "../constants";
import type {
AptosBalance,
AptosTransaction,
StakePoolResource,
TransactionOptions,
} from "../types";
import { GetAccountTransactionsData, GetAccountTransactionsDataGt } from "./graphql/queries";
import type {
GetAccountTransactionsDataQuery,
GetAccountTransactionsDataGtQueryVariables,
TransactionVersion,
} from "./graphql/types";
import {
BlockInfo,
FeeEstimation,
Operation,
TransactionIntent,
} from "@ledgerhq/coin-framework/api/types";
import { log } from "@ledgerhq/logs";
import { transactionsToOperations } from "../logic/transactionsToOperations";
import { isTestnet } from "../logic/isTestnet";
import { normalizeAddress } from "../logic/normalizeAddress";
const getApiEndpoint = (currencyId: string) =>
isTestnet(currencyId) ? getEnv("APTOS_TESTNET_API_ENDPOINT") : getEnv("APTOS_API_ENDPOINT");
const getIndexerEndpoint = (currencyId: string) =>
isTestnet(currencyId)
? getEnv("APTOS_TESTNET_INDEXER_ENDPOINT")
: getEnv("APTOS_INDEXER_ENDPOINT");
const getNetwork = (currencyId: string) =>
isTestnet(currencyId) ? Network.TESTNET : Network.MAINNET;
export class AptosAPI {
private readonly aptosConfig: AptosConfig;
private readonly aptosClient: Aptos;
readonly apolloClient: ApolloClient<object>;
constructor(currencyIdOrSettings: AptosSettings | string) {
const appVersion = getEnv("LEDGER_CLIENT_VERSION");
if (typeof currencyIdOrSettings === "string") {
this.aptosConfig = new AptosConfig({
network: getNetwork(currencyIdOrSettings),
fullnode: getApiEndpoint(currencyIdOrSettings),
indexer: getIndexerEndpoint(currencyIdOrSettings),
clientConfig: {
HEADERS: {
"X-Ledger-Client-Version": appVersion,
},
},
});
} else {
// Ensure the header is present when custom settings are provided
const settings = { ...currencyIdOrSettings };
const existingHeaders = settings.clientConfig?.HEADERS ?? {};
settings.clientConfig = {
...(settings.clientConfig ?? {}),
HEADERS: {
...existingHeaders,
// don’t override if caller already provided it
"X-Ledger-Client-Version": existingHeaders["X-Ledger-Client-Version"] ?? appVersion,
},
};
this.aptosConfig = new AptosConfig(settings);
}
this.aptosClient = new Aptos(this.aptosConfig);
this.apolloClient = new ApolloClient({
uri: this.aptosConfig.indexer ?? "",
cache: new InMemoryCache(),
headers: {
"X-Ledger-Client-Version": appVersion,
},
});
}
async getAccount(address: string): Promise<AccountData> {
return this.aptosClient.getAccountInfo({ accountAddress: address });
}
async getAccountInfo(address: string, startAt?: string) {
const [balance, transactions, blockHeight] = await Promise.all([
this.getBalances(address, APTOS_ASSET_ID),
this.fetchTransactions(address, startAt),
this.getHeight(),
]);
return {
balance: balance[0]?.amount ?? BigNumber(0),
transactions,
blockHeight,
};
}
async estimateGasPrice(): Promise<GasEstimation> {
return this.aptosClient.getGasPriceEstimation();
}
async generateTransaction(
address: string,
payload: InputEntryFunctionData,
options: TransactionOptions,
): Promise<RawTransaction> {
const opts: Partial<InputGenerateTransactionOptions> = {};
if (!isUndefined(options.maxGasAmount)) {
opts.maxGasAmount = Number(options.maxGasAmount);
}
if (!isUndefined(options.gasUnitPrice)) {
opts.gasUnitPrice = Number(options.gasUnitPrice);
}
try {
const { ledger_timestamp } = await this.aptosClient.getLedgerInfo();
opts.expireTimestamp = Number(Math.ceil(+ledger_timestamp / 1_000_000 + 2 * 60)); // in milliseconds
} catch {
// skip
}
return this.aptosClient.transaction.build
.simple({
sender: address,
data: payload,
options: opts,
})
.then(t => t.rawTransaction)
.catch(error => {
throw error;
});
}
async simulateTransaction(
address: Ed25519PublicKey,
tx: RawTransaction,
options = {
estimateGasUnitPrice: true,
estimateMaxGasAmount: true,
estimatePrioritizedGasUnitPrice: false,
},
): Promise<UserTransactionResponse[]> {
return this.aptosClient.transaction.simulate.simple({
signerPublicKey: address,
transaction: { rawTransaction: tx } as SimpleTransaction,
options,
});
}
async broadcast(tx: string): Promise<string> {
const txBytes = Hex.fromHexString(tx).toUint8Array();
const pendingTx = await postAptosFullNode<Uint8Array, PendingTransactionResponse>({
aptosConfig: this.aptosClient.config,
body: txBytes,
path: "transactions",
originMethod: "",
contentType: MimeType.BCS_SIGNED_TRANSACTION,
});
return pendingTx.data.hash;
}
async getLastBlock(): Promise<BlockInfo> {
const { block_height } = await this.aptosClient.getLedgerInfo();
const block = await this.aptosClient.getBlockByHeight({ blockHeight: Number(block_height) });
return {
height: Number(block.block_height),
hash: block.block_hash,
time: new Date(Number(block.block_timestamp) / 1_000),
};
}
async estimateFees(transactionIntent: TransactionIntent): Promise<FeeEstimation> {
const publicKeyEd = new Ed25519PublicKey(transactionIntent?.senderPublicKey ?? "");
const txPayload: InputEntryFunctionData = {
function: "0x1::aptos_account::transfer_coins",
typeArguments: [APTOS_ASSET_ID],
functionArguments: [transactionIntent.recipient, transactionIntent.amount],
};
// TODO: this should be looked over again, might be more precise in terms of types..
if (transactionIntent.asset.type !== "native") {
const { type } = transactionIntent.asset;
if (type === TOKEN_TYPE.FUNGIBLE_ASSET) {
txPayload.function = "0x1::primary_fungible_store::transfer";
txPayload.typeArguments = ["0x1::fungible_asset::Metadata"];
txPayload.functionArguments = [
transactionIntent.asset.assetReference,
transactionIntent.recipient,
transactionIntent.amount,
];
} else if (type === TOKEN_TYPE.COIN) {
txPayload.function = "0x1::aptos_account::transfer_coins";
txPayload.typeArguments = [transactionIntent.asset.assetReference as string];
}
}
const txOptions: TransactionOptions = {
maxGasAmount: DEFAULT_GAS.toString(),
gasUnitPrice: DEFAULT_GAS_PRICE.toString(),
};
const tx = await this.generateTransaction(transactionIntent.sender, txPayload, txOptions);
const simulation = await this.simulateTransaction(publicKeyEd, tx);
const completedTx = simulation[0];
const gasLimit = new BigNumber(completedTx.gas_used)
.multipliedBy(ESTIMATE_GAS_MUL)
.integerValue();
const gasPrice = new BigNumber(completedTx.gas_unit_price);
const expectedGas = gasPrice.multipliedBy(gasLimit);
return {
value: BigInt(expectedGas.toString()),
parameters: {
gasLimit: BigInt(gasLimit.toString()),
gasPrice: BigInt(gasPrice.toString()),
},
};
}
async getNextUnlockTime(stakingPoolAddress: string): Promise<string | undefined> {
const resourceType: MoveStructId = "0x1::stake::StakePool";
try {
const resource = await this.aptosClient.getAccountResource<StakePoolResource>({
accountAddress: stakingPoolAddress,
resourceType,
});
return resource.locked_until_secs;
} catch (error) {
log("error", "Failed to fetch StakePool resource:", { error });
}
}
async getDelegatorBalanceInPool(
poolAddress: string,
delegatorAddress: string,
): Promise<Array<string>> {
try {
// Query the delegator balance in the pool
return await this.aptosClient.view<[string]>({
payload: {
function: "0x1::delegation_pool::get_stake",
typeArguments: [],
functionArguments: [poolAddress, delegatorAddress],
},
});
} catch (error) {
log("error", "Failed to fetch delegation_pool::get_stake", { error });
return ["0", "0", "0"];
}
}
async listOperations(rawAddress: string, minHeight: number): Promise<[Operation[], string]> {
const address = normalizeAddress(rawAddress);
const transactions = await this.getAccountInfo(address, minHeight.toString());
const newOperations = transactionsToOperations(address, transactions.transactions);
return [newOperations, ""];
}
private async getAllTransactions(address: string, gt?: string): Promise<TransactionVersion[]> {
let allTransactions: TransactionVersion[] = [];
let offset = 0;
let query = GetAccountTransactionsData;
if (gt) {
query = GetAccountTransactionsDataGt;
}
const condition = true;
while (condition) {
try {
const queryResponse = await this.apolloClient.query<
GetAccountTransactionsDataQuery,
GetAccountTransactionsDataGtQueryVariables
>({
query,
variables: {
address,
limit: 100,
gt,
offset,
},
fetchPolicy: "network-only",
});
offset += 100;
allTransactions = allTransactions.concat(queryResponse.data.account_transactions);
if (queryResponse.data.account_transactions.length < 100) {
break;
}
} catch (error: any) {
throw new Error(error);
}
}
return allTransactions;
}
private async fetchTransactions(address: string, gt?: string) {
if (!address) {
return [];
}
const transactions = await this.getAllTransactions(address, gt);
return Promise.all(
transactions
.slice()
.sort((a, b) => b.transaction_version - a.transaction_version)
.map(({ transaction_version }) => {
return this.richItemByVersion(transaction_version);
}),
);
}
private async richItemByVersion(version: number): Promise<AptosTransaction | null> {
try {
const tx: TransactionResponse = await this.aptosClient.getTransactionByVersion({
ledgerVersion: version,
});
const block = await this.getBlock(version);
return {
...tx,
block,
} as AptosTransaction;
} catch (error) {
log("error", "richItemByVersion", {
error,
});
return null;
}
}
private async getHeight(): Promise<number> {
const { data } = await network<Block>({
method: "GET",
url: this.aptosConfig.fullnode ?? "",
});
return parseInt(data.block_height);
}
private async getBlock(version: number) {
const block = await this.aptosClient.getBlockByVersion({ ledgerVersion: version });
return {
height: parseInt(block.block_height),
hash: block.block_hash,
};
}
async getBalances(address: string, contractAddress?: string): Promise<AptosBalance[]> {
try {
const whereCondition: any = {
owner_address: { _eq: address },
};
if (contractAddress !== undefined && contractAddress !== "") {
whereCondition.asset_type = { _eq: contractAddress };
}
const response = await this.aptosClient.getCurrentFungibleAssetBalances({
options: {
where: whereCondition,
},
});
return response.map(x => ({
contractAddress: x.asset_type ?? "",
amount: BigNumber(x.amount),
}));
} catch (error) {
log("error", "getCoinBalance", {
error,
});
return [{ amount: BigNumber(0), contractAddress: "" }];
}
}
}