@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.
Files changed (53) hide show
  1. package/.eslintrc.js +57 -0
  2. package/.turbo/turbo-build.log +4 -0
  3. package/CHANGELOG.md +18 -0
  4. package/jest.config.js +6 -0
  5. package/package.json +102 -0
  6. package/src/__tests__/adapters.unit.test.ts +527 -0
  7. package/src/__tests__/broadcast.unit.test.ts +181 -0
  8. package/src/__tests__/buildOptimisticOperation.unit.test.ts +182 -0
  9. package/src/__tests__/createTransaction.unit.test.ts +52 -0
  10. package/src/__tests__/deviceTransactionConfig.unit.test.ts +245 -0
  11. package/src/__tests__/estimateMaxSpendable.unit.test.ts +123 -0
  12. package/src/__tests__/getTransactionStatus.unit.test.ts +355 -0
  13. package/src/__tests__/hw-getAddress.unit.test.ts +24 -0
  14. package/src/__tests__/logic.unit.test.ts +406 -0
  15. package/src/__tests__/preload.unit.test.ts +139 -0
  16. package/src/__tests__/prepareTransaction.unit.test.ts +394 -0
  17. package/src/__tests__/rpc.unit.test.ts +532 -0
  18. package/src/__tests__/signOperation.unit.test.ts +157 -0
  19. package/src/__tests__/synchronization.unit.test.ts +832 -0
  20. package/src/__tests__/transaction.unit.test.ts +196 -0
  21. package/src/abis/erc20.abi.json +230 -0
  22. package/src/abis/optimismGasPriceOracle.abi.json +252 -0
  23. package/src/adapters.ts +148 -0
  24. package/src/api/etherscan.ts +124 -0
  25. package/src/api/rpc.common.ts +354 -0
  26. package/src/api/rpc.native.ts +5 -0
  27. package/src/api/rpc.ts +2 -0
  28. package/src/bridge/js.ts +77 -0
  29. package/src/bridge.integration.test.ts +93 -0
  30. package/src/broadcast.ts +40 -0
  31. package/src/buildOptimisticOperation.ts +113 -0
  32. package/src/cli-transaction.ts +11 -0
  33. package/src/createTransaction.ts +25 -0
  34. package/src/datasets/ethereum.scanAccounts.1.ts +48 -0
  35. package/src/datasets/ethereum1.ts +20 -0
  36. package/src/datasets/ethereum2.ts +20 -0
  37. package/src/datasets/ethereum_classic.ts +68 -0
  38. package/src/deviceTransactionConfig.ts +64 -0
  39. package/src/errors.ts +5 -0
  40. package/src/estimateMaxSpendable.ts +19 -0
  41. package/src/getTransactionStatus.ts +186 -0
  42. package/src/hw-getAddress.ts +24 -0
  43. package/src/logic.ts +149 -0
  44. package/src/preload.ts +54 -0
  45. package/src/prepareTransaction.ts +176 -0
  46. package/src/signOperation.ts +127 -0
  47. package/src/specs.ts +344 -0
  48. package/src/speculos-deviceActions.ts +83 -0
  49. package/src/synchronization.ts +317 -0
  50. package/src/testUtils.ts +153 -0
  51. package/src/transaction.ts +193 -0
  52. package/src/types.ts +132 -0
  53. package/tsconfig.json +12 -0
@@ -0,0 +1,127 @@
1
+ import { Observable } from "rxjs";
2
+ import Eth, { ledgerService } from "@ledgerhq/hw-app-eth";
3
+ import {
4
+ Account,
5
+ SignOperationFnSignature,
6
+ SignOperationEvent,
7
+ } from "@ledgerhq/types-live";
8
+ import { ResolutionConfig } from "@ledgerhq/hw-app-eth/lib/services/types";
9
+ import { buildOptimisticOperation } from "./buildOptimisticOperation";
10
+ import { prepareForSignOperation } from "./prepareTransaction";
11
+ import { getSerializedTransaction } from "./transaction";
12
+ import { Transaction } from "./types";
13
+ import { DeviceCommunication } from "@ledgerhq/coin-framework/bridge/jsHelpers";
14
+
15
+ /**
16
+ * Transforms the ECDSA signature paremeter v hexadecimal string received
17
+ * from the nano into an EIP155 compatible number.
18
+ *
19
+ * Reminder EIP155 transforms v this way:
20
+ * v = chainId * 2 + 35
21
+ * (+ parity 1 or 0)
22
+ */
23
+ export const applyEIP155 = (vAsHex: string, chainId: number): number => {
24
+ const v = parseInt(vAsHex, 16);
25
+
26
+ if (v === 0 || v === 1) {
27
+ // if v is 0 or 1, it's already representing parity
28
+ return chainId * 2 + 35 + v;
29
+ } else if (v === 27 || v === 28) {
30
+ const parity = v - 27; // transforming v into 0 or 1 to become the parity
31
+ return chainId * 2 + 35 + parity;
32
+ }
33
+ // When chainId is lower than 109, hw-app-eth *can* return a v with EIP155 already applied
34
+ // e.g. bsc's chainId is 56 -> v then equals to 147/148
35
+ // optimism's chainId is 10 -> v equals to 55/56
36
+ // ethereum's chainId is 1 -> v equals to 0/1
37
+ // goerli's chainId is 5 -> v equals to 0/1
38
+ return v;
39
+ };
40
+
41
+ /**
42
+ * Sign Transaction with Ledger hardware
43
+ */
44
+ export const buildSignOperation =
45
+ (withDevice: DeviceCommunication): SignOperationFnSignature<Transaction> =>
46
+ ({
47
+ account,
48
+ deviceId,
49
+ transaction,
50
+ }: {
51
+ account: Account;
52
+ deviceId: any;
53
+ transaction: Transaction;
54
+ }): Observable<SignOperationEvent> =>
55
+ withDevice(deviceId)(
56
+ (transport) =>
57
+ new Observable((o) => {
58
+ async function main() {
59
+ const preparedTransaction = await prepareForSignOperation(
60
+ account,
61
+ transaction
62
+ );
63
+ const serializedTxHexString =
64
+ getSerializedTransaction(preparedTransaction).slice(2); // Remove 0x prefix
65
+
66
+ // Configure type of resolutions necessary for the clear signing
67
+ const resolutionConfig: ResolutionConfig = {
68
+ externalPlugins: true,
69
+ erc20: true,
70
+ domains: transaction.recipientDomain
71
+ ? [transaction.recipientDomain]
72
+ : [],
73
+ };
74
+ // Look for resolutions for external plugins and ERC20
75
+ const resolution = await ledgerService.resolveTransaction(
76
+ serializedTxHexString,
77
+ {},
78
+ resolutionConfig
79
+ );
80
+
81
+ o.next({
82
+ type: "device-signature-requested",
83
+ });
84
+
85
+ // Instanciate Eth app bindings
86
+ const eth = new Eth(transport);
87
+ // Request signature on the nano
88
+ const sig = await eth.signTransaction(
89
+ account.freshAddressPath,
90
+ serializedTxHexString,
91
+ resolution
92
+ );
93
+
94
+ o.next({ type: "device-signature-granted" }); // Signature is done
95
+
96
+ const { chainId = 0 } = account.currency.ethereumLikeInfo || {};
97
+ // Create a new serialized tx with the signature now
98
+ const signature = await getSerializedTransaction(
99
+ preparedTransaction,
100
+ {
101
+ r: "0x" + sig.r,
102
+ s: "0x" + sig.s,
103
+ v: applyEIP155(sig.v, chainId),
104
+ }
105
+ );
106
+
107
+ const operation = buildOptimisticOperation(account, {
108
+ ...transaction,
109
+ nonce: preparedTransaction.nonce,
110
+ });
111
+
112
+ o.next({
113
+ type: "signed",
114
+ signedOperation: {
115
+ operation,
116
+ signature,
117
+ expirationDate: null,
118
+ },
119
+ });
120
+ }
121
+
122
+ main().then(
123
+ () => o.complete(),
124
+ (e) => o.error(e)
125
+ );
126
+ })
127
+ );
package/src/specs.ts ADDED
@@ -0,0 +1,344 @@
1
+ import expect from "expect";
2
+ import invariant from "invariant";
3
+ import sample from "lodash/sample";
4
+ import BigNumber from "bignumber.js";
5
+ import {
6
+ MutationSpec,
7
+ TransactionDestinationTestInput,
8
+ } from "@ledgerhq/coin-framework/bot/types";
9
+ import { DeviceModelId } from "@ledgerhq/devices";
10
+ import { CryptoCurrencyIds } from "@ledgerhq/types-live";
11
+ import { cryptocurrenciesById } from "@ledgerhq/cryptoassets/currencies";
12
+ import {
13
+ botTest,
14
+ genericTestDestination,
15
+ pickSiblings,
16
+ } from "@ledgerhq/coin-framework/bot/specs";
17
+ import {
18
+ getCryptoCurrencyById,
19
+ parseCurrencyUnit,
20
+ } from "@ledgerhq/coin-framework/currencies/index";
21
+ import { acceptTransaction } from "./speculos-deviceActions";
22
+ import { Transaction as EvmTransaction } from "./types";
23
+
24
+ const testTimeout = 10 * 60 * 1000;
25
+
26
+ const ETH_UNIT = { code: "ETH", name: "ETH", magnitude: 18 };
27
+ const MBTC_UNIT = { name: "mBTC", code: "mBTC", magnitude: 5 };
28
+
29
+ const minBalancePerCurrencyId: Record<CryptoCurrencyIds, BigNumber> = {
30
+ arbitrum: parseCurrencyUnit(ETH_UNIT, "0.001"),
31
+ arbitrum_goerli: parseCurrencyUnit(ETH_UNIT, "0.001"),
32
+ optimism: parseCurrencyUnit(ETH_UNIT, "0.001"),
33
+ optimism_goerli: parseCurrencyUnit(ETH_UNIT, "0.001"),
34
+ boba: parseCurrencyUnit(ETH_UNIT, "0.001"),
35
+ metis: parseCurrencyUnit(ETH_UNIT, "0.01"),
36
+ moonriver: parseCurrencyUnit(ETH_UNIT, "0.1"),
37
+ rsk: parseCurrencyUnit(MBTC_UNIT, "0.05"),
38
+ };
39
+
40
+ /**
41
+ * Method in charge of verifying that both the recipient and sender of a *coin* transaction
42
+ * have received/lost the expected amount and have now the right balances
43
+ *
44
+ * ⚠️ Some blockchains specific rules are included
45
+ */
46
+ const testCoinDestination = (
47
+ args: TransactionDestinationTestInput<EvmTransaction>
48
+ ) => {
49
+ const { sendingAccount } = args;
50
+ const { currency } = sendingAccount;
51
+
52
+ // Because Arbitrum is an L2, gas is used in a specific way to ensure both the L2 and the L1 are getting paid.
53
+ // But as of right now the `arbiscan.io` API is only returning the proposed gas price of
54
+ // the transaction and not the effectively used gas price, which might differ.
55
+ // This leads to not being able to correctly cost an operation and
56
+ // therefore makes it impossible infer the sender's balance
57
+ if (["arbitrum", "arbitrum_goerli"].includes(currency.id)) {
58
+ return;
59
+ }
60
+
61
+ return genericTestDestination(args);
62
+ };
63
+
64
+ /**
65
+ * Method in charge of verifying the balance of a coin account after
66
+ * the transaction has been confirmed
67
+ *
68
+ * ⚠️ Some blockchains specific rules are included
69
+ */
70
+ const testCoinBalance: MutationSpec<EvmTransaction>["test"] = ({
71
+ account,
72
+ accountBeforeTransaction,
73
+ operation,
74
+ }) => {
75
+ // Optimism works in a way where a transaction as a L2 cost and a L1 settlement cost
76
+ // The explorer API is not capable of returning the L1 cost, therefore
77
+ // the operation value will always be less than what
78
+ // has been removed from the account balance
79
+ //
80
+ // Remark is also true for Abritrum but because of the arbiscan API not returning the
81
+ // effectively used gas but the "bid"/proposition of gas of the transaction
82
+ // resulting in inconsistencies regarding the cumulated value of a tx.
83
+ // value + gasLimit * gasPrice <-- gasPrice can be wrong here.
84
+ const underValuedFeesCurrencies = ["optimism", "optimism_goerli"];
85
+ const overValuedFeesCurrencies = ["arbitrum", "arbitrum_goerli"];
86
+ const currenciesWithFlakyBehaviour = [
87
+ ...underValuedFeesCurrencies,
88
+ ...overValuedFeesCurrencies,
89
+ ];
90
+
91
+ // Classic test verifying exactly the balance
92
+ if (!currenciesWithFlakyBehaviour.includes(account.currency.id)) {
93
+ botTest("account balance moved with operation value", () =>
94
+ expect(account.balance.toString()).toBe(
95
+ accountBeforeTransaction.balance.minus(operation.value).toString()
96
+ )
97
+ );
98
+ } else {
99
+ // fallback test verifying the balance moved between the maximum and minimum possible values of the operation
100
+ botTest(
101
+ "account balance moved at least operation value and less than operation value plus fees",
102
+ () => {
103
+ // If the fee is undervalued like it is for optimism for example
104
+ // the only doable check is to verify that the account
105
+ // has lost *at least* the operation value + fee
106
+ if (underValuedFeesCurrencies.includes(account.currency.id)) {
107
+ const maxBalance = accountBeforeTransaction.balance.minus(
108
+ operation.value.minus(operation.fee) // type OUT operations value includes fees so we remove it
109
+ );
110
+
111
+ expect({
112
+ lessThanMaxBalance: account.balance.lte(maxBalance),
113
+ }).toEqual({
114
+ lessThanMaxBalance: true,
115
+ });
116
+ }
117
+
118
+ // If the fee is overvalue like it is for arbitrum for example
119
+ // we make sure the account has now a balance between
120
+ // previous balance minus only operation value &
121
+ // previous balance minus operation value + fee
122
+ if (overValuedFeesCurrencies.includes(account.currency.id)) {
123
+ const minBalance = accountBeforeTransaction.balance.minus(
124
+ operation.value // type OUT operations value includes fees
125
+ );
126
+ const maxBalance = accountBeforeTransaction.balance.minus(
127
+ operation.value.minus(operation.fee)
128
+ );
129
+
130
+ expect({
131
+ greaterThanMinBalance: account.balance.gte(minBalance),
132
+ lessThanMaxBalance: account.balance.lte(maxBalance),
133
+ }).toEqual({
134
+ greaterThanMinBalance: true,
135
+ lessThanMaxBalance: true,
136
+ });
137
+ }
138
+ }
139
+ );
140
+ }
141
+ };
142
+
143
+ const transactionCheck =
144
+ (currencyId: string) =>
145
+ ({ maxSpendable }: { maxSpendable: BigNumber }) => {
146
+ const currency = getCryptoCurrencyById(currencyId);
147
+ invariant(
148
+ maxSpendable.gt(
149
+ minBalancePerCurrencyId[currency.id] ||
150
+ parseCurrencyUnit(currency.units[0], "1")
151
+ ),
152
+ `${currencyId} balance is too low`
153
+ );
154
+ };
155
+
156
+ const evmBasicMutations: ({
157
+ maxAccount,
158
+ }: {
159
+ maxAccount: number;
160
+ }) => MutationSpec<EvmTransaction>[] = ({ maxAccount }) => [
161
+ {
162
+ name: "move 50%",
163
+ maxRun: 2,
164
+ testDestination: testCoinDestination,
165
+ transaction: ({ account, siblings, bridge, maxSpendable }) => {
166
+ const sibling = pickSiblings(siblings, maxAccount);
167
+ const recipient = sibling.freshAddress;
168
+ const amount = maxSpendable.div(2).integerValue();
169
+ return {
170
+ transaction: bridge.createTransaction(account),
171
+ updates: [
172
+ {
173
+ recipient,
174
+ amount,
175
+ },
176
+ ],
177
+ };
178
+ },
179
+ test: ({
180
+ account,
181
+ accountBeforeTransaction,
182
+ operation,
183
+ transaction,
184
+ status,
185
+ optimisticOperation,
186
+ }) => {
187
+ // workaround for buggy explorer behavior (nodes desync)
188
+ invariant(
189
+ Date.now() - operation.date.getTime() > 60000,
190
+ "operation time to be older than 60s"
191
+ );
192
+ const estimatedGas = transaction.gasLimit.times(
193
+ transaction.gasPrice || transaction.maxFeePerGas || 0
194
+ );
195
+ botTest("operation fee is not exceeding estimated gas", () =>
196
+ expect(operation.fee.toNumber()).toBeLessThanOrEqual(
197
+ estimatedGas.toNumber()
198
+ )
199
+ );
200
+
201
+ testCoinBalance({
202
+ account,
203
+ accountBeforeTransaction,
204
+ operation,
205
+ transaction,
206
+ status,
207
+ optimisticOperation,
208
+ });
209
+ },
210
+ },
211
+ {
212
+ name: "send max",
213
+ maxRun: 1,
214
+ testDestination: testCoinDestination,
215
+ transaction: ({ account, siblings, bridge }) => {
216
+ const sibling = pickSiblings(siblings, maxAccount);
217
+ const recipient = sibling.freshAddress;
218
+
219
+ return {
220
+ transaction: bridge.createTransaction(account),
221
+ updates: [
222
+ {
223
+ recipient,
224
+ },
225
+ {
226
+ useAllAmount: true,
227
+ },
228
+ ],
229
+ };
230
+ },
231
+ test: ({
232
+ account,
233
+ accountBeforeTransaction,
234
+ operation,
235
+ transaction,
236
+ status,
237
+ optimisticOperation,
238
+ }) => {
239
+ // workaround for buggy explorer behavior (nodes desync)
240
+ invariant(
241
+ Date.now() - operation.date.getTime() > 60000,
242
+ "operation time to be older than 60s"
243
+ );
244
+ const estimatedGas = transaction.gasLimit.times(
245
+ transaction.gasPrice || transaction.maxFeePerGas || 0
246
+ );
247
+ botTest("operation fee is not exceeding estimated gas", () =>
248
+ expect(operation.fee.toNumber()).toBeLessThanOrEqual(
249
+ estimatedGas.toNumber()
250
+ )
251
+ );
252
+
253
+ testCoinBalance({
254
+ account,
255
+ accountBeforeTransaction,
256
+ operation,
257
+ transaction,
258
+ status,
259
+ optimisticOperation,
260
+ });
261
+ },
262
+ },
263
+ {
264
+ name: "move some ERC20",
265
+ maxRun: 1,
266
+ transaction: ({ account, siblings, bridge }) => {
267
+ const erc20Account = sample(
268
+ (account.subAccounts || []).filter((a) => a.balance.gt(0))
269
+ );
270
+ invariant(erc20Account, "no erc20 account");
271
+ const sibling = pickSiblings(siblings, 3);
272
+ const recipient = sibling.freshAddress;
273
+ return {
274
+ transaction: bridge.createTransaction(account),
275
+ updates: [
276
+ {
277
+ recipient,
278
+ subAccountId: erc20Account!.id,
279
+ },
280
+ Math.random() < 0.5
281
+ ? {
282
+ useAllAmount: true,
283
+ }
284
+ : {
285
+ amount: erc20Account!.balance
286
+ .times(Math.random())
287
+ .integerValue(),
288
+ },
289
+ ],
290
+ };
291
+ },
292
+ test: ({ accountBeforeTransaction, account, transaction, operation }) => {
293
+ // workaround for buggy explorer behavior (nodes desync)
294
+ invariant(
295
+ Date.now() - operation.date.getTime() > 60000,
296
+ "operation time to be older than 60s"
297
+ );
298
+ invariant(accountBeforeTransaction.subAccounts, "sub accounts before");
299
+ const erc20accountBefore = accountBeforeTransaction.subAccounts?.find(
300
+ (s) => s.id === transaction.subAccountId
301
+ );
302
+ invariant(erc20accountBefore, "erc20 acc was here before");
303
+ invariant(account.subAccounts, "sub accounts");
304
+ const erc20account = account.subAccounts!.find(
305
+ (s) => s.id === transaction.subAccountId
306
+ );
307
+ invariant(erc20account, "erc20 acc is still here");
308
+
309
+ if (transaction.useAllAmount) {
310
+ botTest("erc20 account is empty", () =>
311
+ expect(erc20account!.balance.toString()).toBe("0")
312
+ );
313
+ } else {
314
+ botTest("account balance moved with tx amount", () =>
315
+ expect(erc20account!.balance.toString()).toBe(
316
+ erc20accountBefore!.balance.minus(transaction.amount).toString()
317
+ )
318
+ );
319
+ }
320
+ },
321
+ },
322
+ ];
323
+
324
+ export default Object.values(cryptocurrenciesById)
325
+ .filter((currency) => currency.family === "evm")
326
+ .reduce((acc, currency) => {
327
+ // @ts-expect-error FIXME: fix typings
328
+ acc[currency.id] = {
329
+ name: currency.name,
330
+ currency,
331
+ appQuery: {
332
+ model: DeviceModelId.nanoS,
333
+ appName: "Ethereum",
334
+ appVersion: "1.10.2",
335
+ },
336
+ testTimeout,
337
+ transactionCheck: transactionCheck(currency.id),
338
+ mutations: evmBasicMutations({
339
+ maxAccount: 3,
340
+ }),
341
+ genericDeviceAction: acceptTransaction,
342
+ };
343
+ return acc;
344
+ }, {});
@@ -0,0 +1,83 @@
1
+ import { findSubAccountById } from "@ledgerhq/coin-framework/account/index";
2
+ import {
3
+ deviceActionFlow,
4
+ formatDeviceAmount,
5
+ SpeculosButton,
6
+ } from "@ledgerhq/coin-framework/bot/specs";
7
+ import type { DeviceAction } from "@ledgerhq/coin-framework/bot/types";
8
+ import type { Transaction } from "./types";
9
+
10
+ // FIXME: fix types
11
+ const maxFeesExpectedValue = ({
12
+ account,
13
+ status,
14
+ }: {
15
+ account: any;
16
+ status: any;
17
+ }) => formatDeviceAmount(account.currency, status.estimatedFees);
18
+
19
+ export const acceptTransaction: DeviceAction<Transaction, any> =
20
+ deviceActionFlow({
21
+ steps: [
22
+ {
23
+ title: "Review",
24
+ button: SpeculosButton.RIGHT,
25
+ },
26
+ {
27
+ title: "Type",
28
+ button: SpeculosButton.RIGHT,
29
+ },
30
+ {
31
+ title: "Amount",
32
+ button: SpeculosButton.RIGHT,
33
+ expectedValue: ({ account, status, transaction }) => {
34
+ const subAccount = findSubAccountById(
35
+ account,
36
+ transaction.subAccountId || ""
37
+ );
38
+
39
+ if (subAccount && subAccount.type === "TokenAccount") {
40
+ return formatDeviceAmount(subAccount.token, status.amount);
41
+ }
42
+
43
+ return formatDeviceAmount(account.currency, status.amount);
44
+ },
45
+ },
46
+ {
47
+ title: "Contract",
48
+ button: SpeculosButton.RIGHT,
49
+ },
50
+ {
51
+ title: "Network",
52
+ button: SpeculosButton.RIGHT,
53
+ },
54
+ {
55
+ title: "Max fees",
56
+ button: SpeculosButton.RIGHT,
57
+ expectedValue: maxFeesExpectedValue,
58
+ },
59
+ {
60
+ // Legacy (ETC..)
61
+ title: "Max Fees",
62
+ button: SpeculosButton.RIGHT,
63
+ expectedValue: maxFeesExpectedValue,
64
+ },
65
+ {
66
+ title: "Address",
67
+ button: SpeculosButton.RIGHT,
68
+ expectedValue: ({ transaction }) => transaction.recipient,
69
+ },
70
+ {
71
+ title: "Accept",
72
+ button: SpeculosButton.BOTH,
73
+ },
74
+ {
75
+ title: "Approve",
76
+ button: SpeculosButton.BOTH,
77
+ },
78
+ ],
79
+ });
80
+
81
+ export default {
82
+ acceptTransaction,
83
+ };