@ledgerhq/coin-evm 0.2.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +57 -0
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +18 -0
- package/jest.config.js +6 -0
- package/package.json +102 -0
- package/src/__tests__/adapters.unit.test.ts +527 -0
- package/src/__tests__/broadcast.unit.test.ts +181 -0
- package/src/__tests__/buildOptimisticOperation.unit.test.ts +182 -0
- package/src/__tests__/createTransaction.unit.test.ts +52 -0
- package/src/__tests__/deviceTransactionConfig.unit.test.ts +245 -0
- package/src/__tests__/estimateMaxSpendable.unit.test.ts +123 -0
- package/src/__tests__/getTransactionStatus.unit.test.ts +355 -0
- package/src/__tests__/hw-getAddress.unit.test.ts +24 -0
- package/src/__tests__/logic.unit.test.ts +406 -0
- package/src/__tests__/preload.unit.test.ts +139 -0
- package/src/__tests__/prepareTransaction.unit.test.ts +394 -0
- package/src/__tests__/rpc.unit.test.ts +532 -0
- package/src/__tests__/signOperation.unit.test.ts +157 -0
- package/src/__tests__/synchronization.unit.test.ts +832 -0
- package/src/__tests__/transaction.unit.test.ts +196 -0
- package/src/abis/erc20.abi.json +230 -0
- package/src/abis/optimismGasPriceOracle.abi.json +252 -0
- package/src/adapters.ts +148 -0
- package/src/api/etherscan.ts +124 -0
- package/src/api/rpc.common.ts +354 -0
- package/src/api/rpc.native.ts +5 -0
- package/src/api/rpc.ts +2 -0
- package/src/bridge/js.ts +77 -0
- package/src/bridge.integration.test.ts +93 -0
- package/src/broadcast.ts +40 -0
- package/src/buildOptimisticOperation.ts +113 -0
- package/src/cli-transaction.ts +11 -0
- package/src/createTransaction.ts +25 -0
- package/src/datasets/ethereum.scanAccounts.1.ts +48 -0
- package/src/datasets/ethereum1.ts +20 -0
- package/src/datasets/ethereum2.ts +20 -0
- package/src/datasets/ethereum_classic.ts +68 -0
- package/src/deviceTransactionConfig.ts +64 -0
- package/src/errors.ts +5 -0
- package/src/estimateMaxSpendable.ts +19 -0
- package/src/getTransactionStatus.ts +186 -0
- package/src/hw-getAddress.ts +24 -0
- package/src/logic.ts +149 -0
- package/src/preload.ts +54 -0
- package/src/prepareTransaction.ts +176 -0
- package/src/signOperation.ts +127 -0
- package/src/specs.ts +344 -0
- package/src/speculos-deviceActions.ts +83 -0
- package/src/synchronization.ts +317 -0
- package/src/testUtils.ts +153 -0
- package/src/transaction.ts +193 -0
- package/src/types.ts +132 -0
- package/tsconfig.json +12 -0
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import eip55 from "eip55";
|
|
2
|
+
import { ethers } from "ethers";
|
|
3
|
+
import BigNumber from "bignumber.js";
|
|
4
|
+
import { TokenCurrency } from "@ledgerhq/types-cryptoassets";
|
|
5
|
+
import { Operation, OperationType } from "@ledgerhq/types-live";
|
|
6
|
+
import { findTokenByAddressInCurrency } from "@ledgerhq/cryptoassets";
|
|
7
|
+
import {
|
|
8
|
+
decodeAccountId,
|
|
9
|
+
encodeTokenAccountId,
|
|
10
|
+
} from "@ledgerhq/coin-framework/account/index";
|
|
11
|
+
import { encodeOperationId } from "@ledgerhq/coin-framework/operation";
|
|
12
|
+
import {
|
|
13
|
+
Transaction as EvmTransaction,
|
|
14
|
+
EvmTransactionEIP1559,
|
|
15
|
+
EvmTransactionLegacy,
|
|
16
|
+
EtherscanOperation,
|
|
17
|
+
EtherscanERC20Event,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Adapter to convert a Ledger Live transaction to an Ethers transaction
|
|
22
|
+
*/
|
|
23
|
+
export const transactionToEthersTransaction = (
|
|
24
|
+
tx: EvmTransaction
|
|
25
|
+
): ethers.Transaction => {
|
|
26
|
+
const ethersTx = {
|
|
27
|
+
to: tx.recipient,
|
|
28
|
+
value: ethers.BigNumber.from(tx.amount.toFixed()),
|
|
29
|
+
data: tx.data ? `0x${tx.data.toString("hex")}` : undefined,
|
|
30
|
+
gasLimit: ethers.BigNumber.from(tx.gasLimit.toFixed()),
|
|
31
|
+
nonce: tx.nonce,
|
|
32
|
+
chainId: tx.chainId,
|
|
33
|
+
type: tx.type,
|
|
34
|
+
} as Partial<ethers.Transaction>;
|
|
35
|
+
|
|
36
|
+
// is EIP-1559 transaction (type 2)
|
|
37
|
+
if (tx.type === 2) {
|
|
38
|
+
ethersTx.maxFeePerGas = ethers.BigNumber.from(
|
|
39
|
+
(tx as EvmTransactionEIP1559).maxFeePerGas.toFixed()
|
|
40
|
+
);
|
|
41
|
+
ethersTx.maxPriorityFeePerGas = ethers.BigNumber.from(
|
|
42
|
+
(tx as EvmTransactionEIP1559).maxPriorityFeePerGas.toFixed()
|
|
43
|
+
);
|
|
44
|
+
} else {
|
|
45
|
+
// is Legacy transaction (type 0)
|
|
46
|
+
ethersTx.gasPrice = ethers.BigNumber.from(
|
|
47
|
+
(tx as EvmTransactionLegacy).gasPrice.toFixed()
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return ethersTx as ethers.Transaction;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Adapter to convert an Etherscan-like operation into a Ledger Live Operation
|
|
56
|
+
*/
|
|
57
|
+
export const etherscanOperationToOperation = (
|
|
58
|
+
accountId: string,
|
|
59
|
+
tx: EtherscanOperation
|
|
60
|
+
): Operation => {
|
|
61
|
+
const { xpubOrAddress: address } = decodeAccountId(accountId);
|
|
62
|
+
const checksummedAddress = eip55.encode(address);
|
|
63
|
+
const from = eip55.encode(tx.from);
|
|
64
|
+
const to = tx.to ? eip55.encode(tx.to) : "";
|
|
65
|
+
const value = new BigNumber(tx.value);
|
|
66
|
+
const fee = new BigNumber(tx.gasUsed).times(new BigNumber(tx.gasPrice));
|
|
67
|
+
|
|
68
|
+
const type = ((): OperationType => {
|
|
69
|
+
if (to === checksummedAddress) {
|
|
70
|
+
return "IN";
|
|
71
|
+
}
|
|
72
|
+
if (from === checksummedAddress) {
|
|
73
|
+
return value.eq(0) ? "FEES" : "OUT";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return "NONE";
|
|
77
|
+
})();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
id: encodeOperationId(accountId, tx.hash, type),
|
|
81
|
+
hash: tx.hash,
|
|
82
|
+
type: type,
|
|
83
|
+
value: type === "OUT" || type === "FEES" ? value.plus(fee) : value,
|
|
84
|
+
fee,
|
|
85
|
+
senders: [from],
|
|
86
|
+
recipients: [to],
|
|
87
|
+
blockHeight: parseInt(tx.blockNumber, 10),
|
|
88
|
+
blockHash: tx.blockHash,
|
|
89
|
+
transactionSequenceNumber: parseInt(tx.nonce, 10),
|
|
90
|
+
accountId: accountId,
|
|
91
|
+
date: new Date(parseInt(tx.timeStamp, 10) * 1000),
|
|
92
|
+
extra: {},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Adapter to convert an ERC20 transaction received
|
|
98
|
+
* on etherscan-like APIs into an Operation
|
|
99
|
+
*/
|
|
100
|
+
export const etherscanERC20EventToOperation = (
|
|
101
|
+
accountId: string,
|
|
102
|
+
event: EtherscanERC20Event
|
|
103
|
+
): { tokenCurrency: TokenCurrency; operation: Operation } | null => {
|
|
104
|
+
const { currencyId, xpubOrAddress: address } = decodeAccountId(accountId);
|
|
105
|
+
const tokenCurrency = findTokenByAddressInCurrency(
|
|
106
|
+
event.contractAddress,
|
|
107
|
+
currencyId
|
|
108
|
+
);
|
|
109
|
+
if (!tokenCurrency) return null;
|
|
110
|
+
|
|
111
|
+
const tokenAccountId = encodeTokenAccountId(accountId, tokenCurrency);
|
|
112
|
+
const from = eip55.encode(event.from);
|
|
113
|
+
const to = event.to ? eip55.encode(event.to) : "";
|
|
114
|
+
const value = new BigNumber(event.value);
|
|
115
|
+
const fee = new BigNumber(event.gasUsed).times(new BigNumber(event.gasPrice));
|
|
116
|
+
|
|
117
|
+
const type = ((): OperationType => {
|
|
118
|
+
if (event.contractAddress && to === eip55.encode(address)) {
|
|
119
|
+
return "IN";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (event.contractAddress && from === eip55.encode(address)) {
|
|
123
|
+
return "OUT";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return "NONE";
|
|
127
|
+
})();
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
tokenCurrency,
|
|
131
|
+
operation: {
|
|
132
|
+
id: encodeOperationId(tokenAccountId, event.hash, type),
|
|
133
|
+
hash: event.hash,
|
|
134
|
+
type: type,
|
|
135
|
+
value,
|
|
136
|
+
fee,
|
|
137
|
+
senders: [from],
|
|
138
|
+
recipients: [to],
|
|
139
|
+
contract: tokenCurrency.contractAddress,
|
|
140
|
+
blockHeight: parseInt(event.blockNumber, 10),
|
|
141
|
+
blockHash: event.blockHash,
|
|
142
|
+
transactionSequenceNumber: parseInt(event.nonce, 10),
|
|
143
|
+
accountId: tokenAccountId,
|
|
144
|
+
date: new Date(parseInt(event.timeStamp, 10) * 1000),
|
|
145
|
+
extra: {},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { delay } from "@ledgerhq/live-promise";
|
|
2
|
+
import { Operation } from "@ledgerhq/types-live";
|
|
3
|
+
import axios, { AxiosRequestConfig } from "axios";
|
|
4
|
+
import { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets";
|
|
5
|
+
import { makeLRUCache } from "@ledgerhq/live-network/cache";
|
|
6
|
+
import { EtherscanERC20Event, EtherscanOperation } from "../types";
|
|
7
|
+
import { EtherscanAPIError } from "../errors";
|
|
8
|
+
import {
|
|
9
|
+
etherscanERC20EventToOperation,
|
|
10
|
+
etherscanOperationToOperation,
|
|
11
|
+
} from "../adapters";
|
|
12
|
+
|
|
13
|
+
export const ETHERSCAN_TIMEOUT = 5000; // 5 seconds between 2 calls
|
|
14
|
+
export const DEFAULT_RETRIES_API = 8;
|
|
15
|
+
|
|
16
|
+
async function fetchWithRetries<T>(
|
|
17
|
+
params: AxiosRequestConfig,
|
|
18
|
+
retries = DEFAULT_RETRIES_API
|
|
19
|
+
): Promise<T> {
|
|
20
|
+
try {
|
|
21
|
+
const { data } = await axios.request<{
|
|
22
|
+
status: string;
|
|
23
|
+
message: string;
|
|
24
|
+
result: T;
|
|
25
|
+
}>(params);
|
|
26
|
+
|
|
27
|
+
if (!Number(data.status) && data.message === "NOTOK") {
|
|
28
|
+
throw new EtherscanAPIError(
|
|
29
|
+
"Error while fetching data from Etherscan like API",
|
|
30
|
+
{ params, data }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return data.result;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (retries) {
|
|
37
|
+
// wait the API timeout before trying again
|
|
38
|
+
await delay(ETHERSCAN_TIMEOUT);
|
|
39
|
+
// decrement with prefix here or it won't work
|
|
40
|
+
return fetchWithRetries<T>(params, --retries);
|
|
41
|
+
}
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get all the last "normal" transactions (no tokens / NFTs)
|
|
48
|
+
*/
|
|
49
|
+
export const getLastCoinOperations = makeLRUCache<
|
|
50
|
+
[
|
|
51
|
+
currency: CryptoCurrency,
|
|
52
|
+
address: string,
|
|
53
|
+
accountId: string,
|
|
54
|
+
fromBlock: number
|
|
55
|
+
],
|
|
56
|
+
Operation[]
|
|
57
|
+
>(
|
|
58
|
+
async (currency, address, accountId, fromBlock) => {
|
|
59
|
+
const apiDomain = currency.ethereumLikeInfo?.explorer?.uri;
|
|
60
|
+
if (!apiDomain) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let url = `${apiDomain}/api?module=account&action=txlist&address=${address}&tag=latest&page=1&sort=desc`;
|
|
65
|
+
if (fromBlock) {
|
|
66
|
+
url += `&startBlock=${fromBlock}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ops = await fetchWithRetries<EtherscanOperation[]>({
|
|
70
|
+
method: "GET",
|
|
71
|
+
url,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return ops
|
|
75
|
+
.map((tx) => etherscanOperationToOperation(accountId, tx))
|
|
76
|
+
.filter(Boolean) as Operation[];
|
|
77
|
+
},
|
|
78
|
+
(currency, address, accountId, fromBlock) => accountId + fromBlock,
|
|
79
|
+
{ ttl: 5 * 1000 }
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get all the last ERC20 transactions
|
|
84
|
+
*/
|
|
85
|
+
export const getLastTokenOperations = makeLRUCache<
|
|
86
|
+
[
|
|
87
|
+
currency: CryptoCurrency,
|
|
88
|
+
address: string,
|
|
89
|
+
accountId: string,
|
|
90
|
+
fromBlock: number
|
|
91
|
+
],
|
|
92
|
+
{ tokenCurrency: TokenCurrency; operation: Operation }[]
|
|
93
|
+
>(
|
|
94
|
+
async (currency, address, accountId, fromBlock) => {
|
|
95
|
+
const apiDomain = currency.ethereumLikeInfo?.explorer?.uri;
|
|
96
|
+
if (!apiDomain) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let url = `${apiDomain}/api?module=account&action=tokentx&address=${address}&tag=latest&page=1&sort=desc`;
|
|
101
|
+
if (fromBlock) {
|
|
102
|
+
url += `&startBlock=${fromBlock}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const ops = await fetchWithRetries<EtherscanERC20Event[]>({
|
|
106
|
+
method: "GET",
|
|
107
|
+
url,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return ops
|
|
111
|
+
.map((event) => etherscanERC20EventToOperation(accountId, event))
|
|
112
|
+
.filter(Boolean) as {
|
|
113
|
+
tokenCurrency: TokenCurrency;
|
|
114
|
+
operation: Operation;
|
|
115
|
+
}[];
|
|
116
|
+
},
|
|
117
|
+
(currency, address, accountId, fromBlock) => accountId + fromBlock,
|
|
118
|
+
{ ttl: 5 * 1000 }
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
export default {
|
|
122
|
+
getLastCoinOperations,
|
|
123
|
+
getLastTokenOperations,
|
|
124
|
+
};
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/** ⚠️ keep this order of import. @see https://docs.ethers.io/v5/cookbook/react-native/#cookbook-reactnative ⚠️ */
|
|
2
|
+
import { ethers } from "ethers";
|
|
3
|
+
import BigNumber from "bignumber.js";
|
|
4
|
+
import { log } from "@ledgerhq/logs";
|
|
5
|
+
import { delay } from "@ledgerhq/live-promise";
|
|
6
|
+
import { Account } from "@ledgerhq/types-live";
|
|
7
|
+
import { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
|
|
8
|
+
import { makeLRUCache } from "@ledgerhq/live-network/cache";
|
|
9
|
+
import OptimismGasPriceOracleAbi from "../abis/optimismGasPriceOracle.abi.json";
|
|
10
|
+
import { FeeData, FeeHistory, Transaction as EvmTransaction } from "../types";
|
|
11
|
+
import { GasEstimationError, InsufficientFunds } from "../errors";
|
|
12
|
+
import { transactionToEthersTransaction } from "../adapters";
|
|
13
|
+
import { getSerializedTransaction } from "../transaction";
|
|
14
|
+
import ERC20Abi from "../abis/erc20.abi.json";
|
|
15
|
+
|
|
16
|
+
export const RPC_TIMEOUT = 5000; // wait 5 sec after a fail
|
|
17
|
+
export const DEFAULT_RETRIES_RPC_METHODS = 3;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Cache for RPC providers to avoid recreating the connection on every usage of `withApi`
|
|
21
|
+
* Without this, ethers will create a new provider and use the `eth_chainId` RPC call
|
|
22
|
+
* at instanciation which could result in rate limits being reached
|
|
23
|
+
* on some specific nodes (E.g. the main Optimism RPC)
|
|
24
|
+
*/
|
|
25
|
+
const PROVIDERS_BY_RPC: Record<string, ethers.providers.StaticJsonRpcProvider> =
|
|
26
|
+
{};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Connects to RPC Node
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ Make sure to always use a `StaticJsonRpcProvider` and not a `JsonRpcProvider`
|
|
32
|
+
* because the latter will use a `eth_chainId` before every request in order
|
|
33
|
+
* to check if the node used changed (as per EIP-1193 standard)
|
|
34
|
+
* @see https://github.com/ethers-io/ethers.js/issues/901
|
|
35
|
+
*/
|
|
36
|
+
export async function withApi<T>(
|
|
37
|
+
currency: CryptoCurrency,
|
|
38
|
+
execute: (api: ethers.providers.StaticJsonRpcProvider) => Promise<T>,
|
|
39
|
+
retries = DEFAULT_RETRIES_RPC_METHODS
|
|
40
|
+
): Promise<T> {
|
|
41
|
+
if (!currency?.ethereumLikeInfo?.rpc) {
|
|
42
|
+
throw new Error("Currency doesn't have an RPC node provided");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
if (!PROVIDERS_BY_RPC[currency.ethereumLikeInfo.rpc]) {
|
|
47
|
+
PROVIDERS_BY_RPC[currency.ethereumLikeInfo.rpc] =
|
|
48
|
+
new ethers.providers.StaticJsonRpcProvider(
|
|
49
|
+
currency.ethereumLikeInfo.rpc
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const provider = PROVIDERS_BY_RPC[currency.ethereumLikeInfo.rpc];
|
|
54
|
+
return await execute(provider);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (retries) {
|
|
57
|
+
// wait the RPC timeout before trying again
|
|
58
|
+
await delay(RPC_TIMEOUT);
|
|
59
|
+
// decrement with prefix here or it won't work
|
|
60
|
+
return withApi<T>(currency, execute, --retries);
|
|
61
|
+
}
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get account balance and last chain block
|
|
68
|
+
*/
|
|
69
|
+
export const getBalanceAndBlock: (
|
|
70
|
+
currency: CryptoCurrency,
|
|
71
|
+
addr: string
|
|
72
|
+
) => Promise<{ blockHeight: number; balance: BigNumber }> = async (
|
|
73
|
+
currency,
|
|
74
|
+
addr
|
|
75
|
+
) =>
|
|
76
|
+
withApi(currency, async (api) => {
|
|
77
|
+
const [balance, blockHeight] = await Promise.all([
|
|
78
|
+
getCoinBalance(currency, addr),
|
|
79
|
+
api.getBlockNumber(),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
blockHeight,
|
|
84
|
+
balance: new BigNumber(balance.toString()),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get a transaction by hash
|
|
90
|
+
*/
|
|
91
|
+
export const getTransaction = (
|
|
92
|
+
currency: CryptoCurrency,
|
|
93
|
+
hash: string
|
|
94
|
+
): Promise<ethers.providers.TransactionResponse> =>
|
|
95
|
+
withApi(currency, (api) => {
|
|
96
|
+
return api.getTransaction(hash);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the balance of an address
|
|
101
|
+
*/
|
|
102
|
+
export const getCoinBalance = (
|
|
103
|
+
currency: CryptoCurrency,
|
|
104
|
+
address: string
|
|
105
|
+
): Promise<BigNumber> =>
|
|
106
|
+
withApi(currency, async (api) => {
|
|
107
|
+
const balance = await api.getBalance(address);
|
|
108
|
+
return new BigNumber(balance.toString());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the balance of an address
|
|
113
|
+
*/
|
|
114
|
+
export const getTokenBalance = (
|
|
115
|
+
currency: CryptoCurrency,
|
|
116
|
+
address: string,
|
|
117
|
+
contractAddress: string
|
|
118
|
+
): Promise<BigNumber> =>
|
|
119
|
+
withApi(currency, async (api) => {
|
|
120
|
+
const erc20 = new ethers.Contract(contractAddress, ERC20Abi, api);
|
|
121
|
+
const balance = await erc20.balanceOf(address);
|
|
122
|
+
return new BigNumber(balance.toString());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get account nonce
|
|
127
|
+
*/
|
|
128
|
+
export const getTransactionCount = (
|
|
129
|
+
currency: CryptoCurrency,
|
|
130
|
+
addr: string
|
|
131
|
+
): Promise<number> =>
|
|
132
|
+
withApi(currency, async (api) => {
|
|
133
|
+
return api.getTransactionCount(addr);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get an estimated gas limit for a transaction
|
|
138
|
+
*/
|
|
139
|
+
export const getGasEstimation = (
|
|
140
|
+
account: Account,
|
|
141
|
+
transaction: EvmTransaction
|
|
142
|
+
): Promise<BigNumber> =>
|
|
143
|
+
withApi(account.currency, async (api) => {
|
|
144
|
+
const { to, value, data } = transactionToEthersTransaction(transaction);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const gasEtimation = await api.estimateGas({
|
|
148
|
+
from: account.freshAddress, // should be necessary for some estimations
|
|
149
|
+
to,
|
|
150
|
+
value,
|
|
151
|
+
data,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return new BigNumber(gasEtimation.toString());
|
|
155
|
+
} catch (e) {
|
|
156
|
+
log("error", "EVM Family: Gas Estimation Error", e);
|
|
157
|
+
throw new GasEstimationError();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get an estimation of fees on the network
|
|
163
|
+
*/
|
|
164
|
+
export const getFeesEstimation = (currency: CryptoCurrency): Promise<FeeData> =>
|
|
165
|
+
withApi(currency, async (api) => {
|
|
166
|
+
const block = await api.getBlock("latest");
|
|
167
|
+
const currencySupports1559 = Boolean(block.baseFeePerGas);
|
|
168
|
+
|
|
169
|
+
const feeData = await (async () => {
|
|
170
|
+
if (currencySupports1559) {
|
|
171
|
+
const feeHistory: FeeHistory = await api.send("eth_feeHistory", [
|
|
172
|
+
"0x5", // Fetching the history for 5 blocks
|
|
173
|
+
"latest", // from the latest block
|
|
174
|
+
[50], // 50% percentile sample
|
|
175
|
+
]);
|
|
176
|
+
// Taking the average priority fee used on the last 5 blocks
|
|
177
|
+
const maxPriorityFeeAverage = feeHistory.reward
|
|
178
|
+
.reduce(
|
|
179
|
+
(acc, [curr]) => acc.plus(new BigNumber(curr)),
|
|
180
|
+
new BigNumber(0)
|
|
181
|
+
)
|
|
182
|
+
.dividedToIntegerBy(feeHistory.reward.length);
|
|
183
|
+
|
|
184
|
+
// A maxPriorityFeePerGas too low might make a transaction stuck forever
|
|
185
|
+
// As a safety measure, if maxPriorityFeePerGas is zero
|
|
186
|
+
// we enforce a 1 Gwei value
|
|
187
|
+
const maxPriorityFeePerGas = maxPriorityFeeAverage.isZero()
|
|
188
|
+
? new BigNumber(1e9) // 1 Gwei
|
|
189
|
+
: maxPriorityFeeAverage;
|
|
190
|
+
|
|
191
|
+
const nextBaseFee = new BigNumber(
|
|
192
|
+
feeHistory.baseFeePerGas[feeHistory.baseFeePerGas.length - 1]
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
maxPriorityFeePerGas,
|
|
197
|
+
maxFeePerGas: nextBaseFee.multipliedBy(2).plus(maxPriorityFeePerGas),
|
|
198
|
+
};
|
|
199
|
+
} else {
|
|
200
|
+
const gasPrice = await api.getGasPrice();
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
gasPrice,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
maxFeePerGas: feeData.maxFeePerGas
|
|
210
|
+
? new BigNumber(feeData.maxFeePerGas.toString())
|
|
211
|
+
: null,
|
|
212
|
+
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas
|
|
213
|
+
? new BigNumber(feeData.maxPriorityFeePerGas.toString())
|
|
214
|
+
: null,
|
|
215
|
+
gasPrice: feeData.gasPrice
|
|
216
|
+
? new BigNumber(feeData.gasPrice.toString())
|
|
217
|
+
: null,
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Broadcast a serialized transaction
|
|
223
|
+
*/
|
|
224
|
+
export const broadcastTransaction = (
|
|
225
|
+
currency: CryptoCurrency,
|
|
226
|
+
signedTxHex: string
|
|
227
|
+
): Promise<ethers.providers.TransactionResponse> =>
|
|
228
|
+
withApi(
|
|
229
|
+
currency,
|
|
230
|
+
async (api) => {
|
|
231
|
+
try {
|
|
232
|
+
return await api.sendTransaction(signedTxHex);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
if ((e as Error & { code: string }).code === "INSUFFICIENT_FUNDS") {
|
|
235
|
+
log("error", "EVM Family: Wrong estimation of fees", e);
|
|
236
|
+
throw new InsufficientFunds();
|
|
237
|
+
}
|
|
238
|
+
throw e;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
0
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get the informations about a block by block height
|
|
246
|
+
*/
|
|
247
|
+
export const getBlock = (
|
|
248
|
+
currency: CryptoCurrency,
|
|
249
|
+
blockHeight: number
|
|
250
|
+
): Promise<ethers.providers.Block> =>
|
|
251
|
+
withApi(currency, async (api) => {
|
|
252
|
+
return api.getBlock(blockHeight);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get account balances and nonce
|
|
257
|
+
*/
|
|
258
|
+
export const getSubAccount: (
|
|
259
|
+
currency: CryptoCurrency,
|
|
260
|
+
addr: string
|
|
261
|
+
) => Promise<{
|
|
262
|
+
blockHeight: number;
|
|
263
|
+
balance: BigNumber;
|
|
264
|
+
nonce: number;
|
|
265
|
+
}> = async (currency, addr) =>
|
|
266
|
+
withApi(currency, async (api) => {
|
|
267
|
+
const [balance, nonce, blockHeight] = await Promise.all([
|
|
268
|
+
getCoinBalance(currency, addr),
|
|
269
|
+
getTransactionCount(currency, addr),
|
|
270
|
+
api.getBlockNumber(),
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
blockHeight,
|
|
275
|
+
balance: new BigNumber(balance.toString()),
|
|
276
|
+
nonce,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* ⚠️ Blockchain specific
|
|
282
|
+
*
|
|
283
|
+
* For a layer 2 like Optimism, additional fees are needed in order to
|
|
284
|
+
* take into account layer 1 settlement estimated cost.
|
|
285
|
+
* This gas price is served through a smart contract oracle.
|
|
286
|
+
*
|
|
287
|
+
* @see https://help.optimism.io/hc/en-us/articles/4411895794715-How-do-transaction-fees-on-Optimism-work-
|
|
288
|
+
*/
|
|
289
|
+
export const getOptimismAdditionalFees = makeLRUCache<
|
|
290
|
+
[CryptoCurrency, EvmTransaction],
|
|
291
|
+
BigNumber
|
|
292
|
+
>(
|
|
293
|
+
async (currency, transaction) =>
|
|
294
|
+
withApi(currency, async (api) => {
|
|
295
|
+
if (!["optimism", "optimism_goerli"].includes(currency.id)) {
|
|
296
|
+
return new BigNumber(0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Fake signature is added to get the best approximation possible for the gas on L1
|
|
300
|
+
const serializedTransaction = (() => {
|
|
301
|
+
try {
|
|
302
|
+
return getSerializedTransaction(transaction, {
|
|
303
|
+
r: "0xffffffffffffffffffffffffffffffffffffffff",
|
|
304
|
+
s: "0xffffffffffffffffffffffffffffffffffffffff",
|
|
305
|
+
v: 0,
|
|
306
|
+
});
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
if (!serializedTransaction) {
|
|
312
|
+
return new BigNumber(0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const optimismGasOracle = new ethers.Contract(
|
|
316
|
+
// contract address provided here
|
|
317
|
+
// @see https://community.optimism.io/docs/developers/build/transaction-fees/#displaying-fees-to-users
|
|
318
|
+
"0x420000000000000000000000000000000000000F",
|
|
319
|
+
OptimismGasPriceOracleAbi,
|
|
320
|
+
api
|
|
321
|
+
);
|
|
322
|
+
const additionalL1Fees = await optimismGasOracle.getL1Fee(
|
|
323
|
+
serializedTransaction
|
|
324
|
+
);
|
|
325
|
+
return new BigNumber(additionalL1Fees.toString());
|
|
326
|
+
}),
|
|
327
|
+
(currency, transaction) => {
|
|
328
|
+
const serializedTransaction = (() => {
|
|
329
|
+
try {
|
|
330
|
+
return getSerializedTransaction(transaction);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
})();
|
|
335
|
+
|
|
336
|
+
return "getOptimismL1BaseFee_" + currency.id + "_" + serializedTransaction;
|
|
337
|
+
},
|
|
338
|
+
{ ttl: 15 * 1000 } // preventing rate limit by caching this for at least 15sec
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
export default {
|
|
342
|
+
DEFAULT_RETRIES_RPC_METHODS,
|
|
343
|
+
RPC_TIMEOUT,
|
|
344
|
+
withApi,
|
|
345
|
+
getBalanceAndBlock,
|
|
346
|
+
getTransaction,
|
|
347
|
+
getCoinBalance,
|
|
348
|
+
getTransactionCount,
|
|
349
|
+
getGasEstimation,
|
|
350
|
+
getFeesEstimation,
|
|
351
|
+
broadcastTransaction,
|
|
352
|
+
getBlock,
|
|
353
|
+
getOptimismAdditionalFees,
|
|
354
|
+
};
|
package/src/api/rpc.ts
ADDED
package/src/bridge/js.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import getAddressWrapper from "@ledgerhq/coin-framework/bridge/getAddressWrapper";
|
|
2
|
+
import {
|
|
3
|
+
DeviceCommunication,
|
|
4
|
+
makeAccountBridgeReceive,
|
|
5
|
+
makeScanAccounts,
|
|
6
|
+
} from "@ledgerhq/coin-framework/bridge/jsHelpers";
|
|
7
|
+
import type { AccountBridge, CurrencyBridge } from "@ledgerhq/types-live";
|
|
8
|
+
import { broadcast } from "../broadcast";
|
|
9
|
+
import { createTransaction } from "../createTransaction";
|
|
10
|
+
import { estimateMaxSpendable } from "../estimateMaxSpendable";
|
|
11
|
+
import { getTransactionStatus } from "../getTransactionStatus";
|
|
12
|
+
import getAddress from "../hw-getAddress";
|
|
13
|
+
import { hydrate, preload } from "../preload";
|
|
14
|
+
import { prepareTransaction } from "../prepareTransaction";
|
|
15
|
+
import { buildSignOperation } from "../signOperation";
|
|
16
|
+
import { getAccountShape, sync } from "../synchronization";
|
|
17
|
+
import type { Transaction as EvmTransaction, Transaction } from "../types";
|
|
18
|
+
|
|
19
|
+
const updateTransaction: AccountBridge<EvmTransaction>["updateTransaction"] = (
|
|
20
|
+
transaction,
|
|
21
|
+
patch
|
|
22
|
+
) => {
|
|
23
|
+
return { ...transaction, ...patch } as EvmTransaction;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function buildCurrencyBridge(
|
|
27
|
+
deviceCommunication: DeviceCommunication
|
|
28
|
+
): CurrencyBridge {
|
|
29
|
+
const scanAccounts = makeScanAccounts({
|
|
30
|
+
getAccountShape,
|
|
31
|
+
deviceCommunication,
|
|
32
|
+
getAddressFn: getAddress,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
preload,
|
|
37
|
+
hydrate,
|
|
38
|
+
scanAccounts,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildAccountBridge(
|
|
43
|
+
deviceCommunication: DeviceCommunication
|
|
44
|
+
): AccountBridge<Transaction> {
|
|
45
|
+
const receive = makeAccountBridgeReceive(
|
|
46
|
+
getAddressWrapper(getAddress),
|
|
47
|
+
deviceCommunication
|
|
48
|
+
);
|
|
49
|
+
const signOperation = buildSignOperation(deviceCommunication);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
createTransaction,
|
|
53
|
+
updateTransaction,
|
|
54
|
+
prepareTransaction,
|
|
55
|
+
getTransactionStatus,
|
|
56
|
+
sync,
|
|
57
|
+
receive,
|
|
58
|
+
signOperation,
|
|
59
|
+
broadcast,
|
|
60
|
+
estimateMaxSpendable,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* FIXME: unsued network and cacheFn are passed to createBridges because of how the
|
|
66
|
+
* libs/ledger-live-common/scripts/sync-families-dispatch.mjs script works.
|
|
67
|
+
*/
|
|
68
|
+
export function createBridges(
|
|
69
|
+
deviceCommunication: DeviceCommunication,
|
|
70
|
+
_network: unknown,
|
|
71
|
+
_cacheFn: unknown
|
|
72
|
+
) {
|
|
73
|
+
return {
|
|
74
|
+
currencyBridge: buildCurrencyBridge(deviceCommunication),
|
|
75
|
+
accountBridge: buildAccountBridge(deviceCommunication),
|
|
76
|
+
};
|
|
77
|
+
}
|