diff --git a/.changeset/new-colts-cover.md b/.changeset/new-colts-cover.md new file mode 100644 index 000000000000..5ff86cfd33b2 --- /dev/null +++ b/.changeset/new-colts-cover.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/coin-module-boilerplate": patch +--- + +feat: coin module boilerplate diff --git a/libs/coin-modules/coin-module-boilerplate/.env.integ.test.example b/libs/coin-modules/coin-module-boilerplate/.env.integ.test.example new file mode 100644 index 000000000000..5acdd1b43843 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/.env.integ.test.example @@ -0,0 +1,2 @@ +PUB_KEY = "XPUB" +SECRET_KEY = "SECRET" diff --git a/libs/coin-modules/coin-module-boilerplate/.eslintrc.js b/libs/coin-modules/coin-module-boilerplate/.eslintrc.js new file mode 100644 index 000000000000..1c935f28c294 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/.eslintrc.js @@ -0,0 +1,22 @@ +module.exports = { + env: { + browser: true, + es6: true, + }, + overrides: [ + { + files: ["src/**/*.test.{ts,tsx}"], + env: { + "jest/globals": true, + }, + plugins: ["jest"], + }, + ], + rules: { + "no-console": ["error", { allow: ["warn", "error"] }], + "@typescript-eslint/no-empty-function": "off", + "no-empty-pattern": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "warn", + }, +}; diff --git a/libs/coin-modules/coin-module-boilerplate/.gitignore b/libs/coin-modules/coin-module-boilerplate/.gitignore new file mode 100644 index 000000000000..0dc34b64922c --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/.gitignore @@ -0,0 +1 @@ +.env.integ.test diff --git a/libs/coin-modules/coin-module-boilerplate/.unimportedrc.json b/libs/coin-modules/coin-module-boilerplate/.unimportedrc.json new file mode 100644 index 000000000000..d08dc4cfb141 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/.unimportedrc.json @@ -0,0 +1,29 @@ +{ + "entry": [ + "src/api/index.ts", + "src/bridge/index.ts", + "src/bridge/deviceTransactionConfig.ts", + "src/datasets/dataset-1.ts", + "src/signer/index.ts", + "src/test/index.ts", + "src/index.ts" + ], + "ignorePatterns": [ + "**/node_modules/**", + "**/*.fixture.ts", + "**/*.mock.ts", + "**/*.test.{js,jsx,ts,tsx}" + ], + "ignoreUnresolved": [ + "jest-get-type", + "jest-matcher-utils", + "jest-message-util" + ], + "ignoreUnimported": [ + "src/network/mock-network.ts", + "src/test/bot-specs.ts" + ], + "ignoreUnused": [ + "@ledgerhq/devices" + ] +} \ No newline at end of file diff --git a/libs/coin-modules/coin-module-boilerplate/jest.config.js b/libs/coin-modules/coin-module-boilerplate/jest.config.js new file mode 100644 index 000000000000..5a008c9e0912 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ +// `workerThreads: true` is required for validating object with `bigint` values +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testPathIgnorePatterns: ["lib/", "lib-es/", ".*\\.integ\\.test\\.[tj]s"], + workerThreads: true, +}; diff --git a/libs/coin-modules/coin-module-boilerplate/jest.integ.config.js b/libs/coin-modules/coin-module-boilerplate/jest.integ.config.js new file mode 100644 index 000000000000..41ad009607bb --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/jest.integ.config.js @@ -0,0 +1,8 @@ +/** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testRegex: ".integ.test.ts$", + testPathIgnorePatterns: ["lib/", "lib-es/"], + setupFiles: ["dotenv/config"], +}; diff --git a/libs/coin-modules/coin-module-boilerplate/package.json b/libs/coin-modules/coin-module-boilerplate/package.json new file mode 100644 index 000000000000..38e36e0de5dd --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/package.json @@ -0,0 +1,136 @@ +{ + "name": "@ledgerhq/coin-module-boilerplate", + "version": "0.1.0", + "description": "Boilerplate coin integration", + "keywords": [ + "Ledger", + "LedgerWallet", + "boilerplate", + "Hardware Wallet" + ], + "repository": { + "type": "git", + "url": "https://github.com/LedgerHQ/ledger-live.git" + }, + "bugs": { + "url": "https://github.com/LedgerHQ/ledger-live/issues" + }, + "homepage": "https://github.com/LedgerHQ/ledger-live/tree/develop/libs/coin-modules/coin-module-boilerplate", + "publishConfig": { + "access": "public" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib/*" + ], + "lib-es/*": [ + "lib-es/*" + ], + "api": [ + "lib/api/index" + ], + "deviceTransactionConfig": [ + "lib/bridge/deviceTransactionConfig" + ], + "logic": [ + "lib/logic/index" + ], + "specs": [ + "lib/test/bot-specs" + ], + "transaction": [ + "lib/bridge/transaction" + ], + "types": [ + "lib/types/index" + ], + "*": [ + "lib/*", + "lib/bridge/*", + "lib/logic/*", + "lib/signer/*", + "lib/test/*", + "lib/types/*" + ] + } + }, + "exports": { + "./lib/*": "./lib/*.js", + "./lib-es/*": "./lib-es/*.js", + "./api": { + "require": "./lib/api/index.js", + "default": "./lib-es/api/index.js" + }, + "./deviceTransactionConfig": { + "require": "./lib/bridge/deviceTransactionConfig.js", + "default": "./lib-es/bridge/deviceTransactionConfig.js" + }, + "./logic": { + "require": "./lib/logic/index.js", + "default": "./lib-es/logic/index.js" + }, + "./signer": { + "require": "./lib/signer/index.js", + "default": "./lib-es/signer/index.js" + }, + "./specs": { + "require": "./lib/test/bot-specs.js", + "default": "./lib-es/test/bot-specs.js" + }, + "./transaction": { + "require": "./lib/bridge/transaction.js", + "default": "./lib-es/bridge/transaction.js" + }, + "./types": { + "require": "./lib/types/index.js", + "default": "./lib-es/types/index.js" + }, + "./*": { + "require": "./lib/*.js", + "default": "./lib-es/*.js" + }, + ".": { + "require": "./lib/index.js", + "default": "./lib-es/index.js" + }, + "./package.json": "./package.json" + }, + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/coin-framework": "workspace:^", + "@ledgerhq/cryptoassets": "workspace:^", + "@ledgerhq/devices": "workspace:^", + "@ledgerhq/errors": "workspace:^", + "@ledgerhq/live-network": "workspace:^", + "@ledgerhq/live-env": "workspace:^", + "@ledgerhq/types-live": "workspace:^", + "bignumber.js": "^9.1.2", + "invariant": "^2.2.4", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@ledgerhq/types-cryptoassets": "workspace:^", + "@types/invariant": "^2.2.37", + "@types/jest": "^29.5.12", + "dotenv": "^16.4.5", + "expect": "^27.4.6", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "typescript": "^5.4.5" + }, + "scripts": { + "clean": "rimraf lib lib-es", + "build": "tsc && tsc -m ES6 --outDir lib-es", + "coverage": "jest --coverage --testPathIgnorePatterns='/bridge.integration.test.ts|node_modules|lib-es|lib/' --passWithNoTests && mv coverage/coverage-final.json coverage/coverage-boilerplate.json", + "prewatch": "pnpm build", + "watch": "tsc --watch", + "doc": "documentation readme src/** --section=API --pe ts --re ts --re d.ts", + "lint": "eslint ./src --no-error-on-unmatched-pattern --ext .ts,.tsx --cache", + "lint:fix": "pnpm lint --fix", + "test": "jest", + "test-integ": "DOTENV_CONFIG_PATH=.env.integ.test jest --config=jest.integ.config.js", + "typecheck": "tsc --noEmit", + "unimported": "unimported" + } +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/api/index.test.ts b/libs/coin-modules/coin-module-boilerplate/src/api/index.test.ts new file mode 100644 index 000000000000..959721ea7e99 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/api/index.test.ts @@ -0,0 +1,15 @@ +import { createApi } from "."; +import { BoilerplateConfig } from "../config"; + +describe("createApi", () => { + it("should return every api methods", () => { + const api = createApi({} as BoilerplateConfig); + expect(api.broadcast).toBeDefined(); + expect(api.combine).toBeDefined(); + expect(api.craftTransaction).toBeDefined(); + expect(api.estimateFees).toBeDefined(); + expect(api.getBalance).toBeDefined(); + expect(api.lastBlock).toBeDefined(); + expect(api.listOperations).toBeDefined(); + }); +}); diff --git a/libs/coin-modules/coin-module-boilerplate/src/api/index.ts b/libs/coin-modules/coin-module-boilerplate/src/api/index.ts new file mode 100644 index 000000000000..4cbcc854b65d --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/api/index.ts @@ -0,0 +1,56 @@ +import type { Api } from "@ledgerhq/coin-framework/api/index"; +import coinConfig, { type BoilerplateConfig } from "../config"; +import { + broadcast, + combine, + craftTransaction, + estimateFees, + getBalance, + getNextValidSequence, + lastBlock, + listOperations, +} from "../common-logic"; +import BigNumber from "bignumber.js"; + +export function createApi(config: BoilerplateConfig): Api { + coinConfig.setCoinConfig(() => ({ ...config, status: { type: "active" } })); + + return { + broadcast, + combine, + craftTransaction: craft, + estimateFees: estimate, + getBalance, + lastBlock, + listOperations, + }; +} + +async function craft( + address: string, + transaction: { + recipient: string; + amount: bigint; + fee: bigint; + }, +): Promise { + const nextSequenceNumber = await getNextValidSequence(address); + const tx = await craftTransaction( + { address, nextSequenceNumber }, + { + recipient: transaction.recipient, + amount: new BigNumber(transaction.amount.toString()), + fee: new BigNumber(transaction.fee.toString()), + }, + ); + return tx.serializedTransaction; +} + +// +async function estimate(addr: string, amount: bigint): Promise { + const { serializedTransaction } = await craftTransaction( + { address: addr }, + { amount: new BigNumber(amount.toString()) }, + ); + return BigInt((await estimateFees(serializedTransaction)).toString()); +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/broadcast.test.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/broadcast.test.ts new file mode 100644 index 000000000000..3e571155cfdb --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/broadcast.test.ts @@ -0,0 +1,36 @@ +import { Account, BroadcastArg } from "@ledgerhq/types-live"; +import { broadcast } from "./broadcast"; +jest.mock("@ledgerhq/coin-framework/operation"); +jest.mock("../common-logic"); +import { patchOperationWithHash } from "@ledgerhq/coin-framework/operation"; +import { broadcast as broadcastLogic } from "../common-logic"; + +describe("broadcast", () => { + let patchOperationSpy: jest.SpyInstance; + let broadcastSpy: jest.SpyInstance; + beforeEach(() => { + patchOperationSpy = jest.spyOn({ patchOperationWithHash }, "patchOperationWithHash"); + broadcastSpy = jest.spyOn({ broadcastLogic }, "broadcastLogic"); + broadcastSpy.mockResolvedValue("hash"); + }); + + it("should broadcast", () => { + broadcast({ + signedOperation: { + signature: undefined, + operation: undefined, + }, + } as unknown as BroadcastArg); + expect(broadcastLogic).toHaveBeenCalledTimes(1); + }); + + it("should patch operation with hash", () => { + broadcast({ + signedOperation: { + signature: undefined, + operation: undefined, + }, + } as unknown as BroadcastArg); + expect(patchOperationSpy).toHaveBeenCalledWith(undefined, "hash"); + }); +}); diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/broadcast.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/broadcast.ts new file mode 100644 index 000000000000..f9a8e381de1b --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/broadcast.ts @@ -0,0 +1,11 @@ +import { AccountBridge } from "@ledgerhq/types-live"; +import { patchOperationWithHash } from "@ledgerhq/coin-framework/operation"; +import { broadcast as broadcastLogic } from "../common-logic"; +import { Transaction } from "../types"; + +export const broadcast: AccountBridge["broadcast"] = async ({ + signedOperation: { signature, operation }, +}) => { + const hash = await broadcastLogic(signature); + return patchOperationWithHash(operation, hash); +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/createTransaction.test.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/createTransaction.test.ts new file mode 100644 index 000000000000..ffe4f3650912 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/createTransaction.test.ts @@ -0,0 +1,12 @@ +import { Account, AccountLike } from "@ledgerhq/types-live"; +import { createTransaction } from "./createTransaction"; + +describe("createTransaction", () => { + it("should create a 0 amount transaction", () => { + expect(createTransaction({} as AccountLike).amount.toNumber()).toEqual(0); + }); + + it("should create a transaction with boilerplate family", () => { + expect(createTransaction({} as AccountLike).family).toEqual("boilerplate"); + }); +}); diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/createTransaction.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/createTransaction.ts new file mode 100644 index 000000000000..01566ef81b98 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/createTransaction.ts @@ -0,0 +1,14 @@ +import BigNumber from "bignumber.js"; +import { Transaction } from "../types"; +import { AccountBridge } from "@ledgerhq/types-live"; + +// We create an empty transaction that will be filled later +export const createTransaction: AccountBridge["createTransaction"] = () => ({ + family: "boilerplate", + amount: new BigNumber(0), + recipient: "", + fee: null, + memo: undefined, + networkInfo: null, + feeCustomUnit: null, +}); diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/deviceTransactionConfig.test.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/deviceTransactionConfig.test.ts new file mode 100644 index 000000000000..6169ab67b958 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/deviceTransactionConfig.test.ts @@ -0,0 +1,22 @@ +import getDeviceTransactionConfig from "./deviceTransactionConfig"; +import BigNumber from "bignumber.js"; + +describe("getDeviceTransactionConfig", () => { + it("should return amount field when it's more than 0", () => { + expect( + getDeviceTransactionConfig({ + transaction: {}, + status: { amount: new BigNumber(1), estimatedFees: new BigNumber(0) }, + } as any)[0], + ).toEqual({ type: "amount", label: "Amount" }); + }); + + it("should return fee field when it's more than 0", () => { + expect( + getDeviceTransactionConfig({ + transaction: {}, + status: { amount: new BigNumber(0), estimatedFees: new BigNumber(1) }, + } as any)[0], + ).toEqual({ type: "fees", label: "Fees" }); + }); +}); diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/deviceTransactionConfig.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/deviceTransactionConfig.ts new file mode 100644 index 000000000000..fb3fabdadbe2 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/deviceTransactionConfig.ts @@ -0,0 +1,34 @@ +import type { AccountLike, Account } from "@ledgerhq/types-live"; +import type { Transaction, TransactionStatus } from "../types"; +import type { CommonDeviceTransactionField } from "@ledgerhq/coin-framework/transaction/common"; + +// This method adds additional fields that need to be reviewed when signing a transaction on the device. +function getDeviceTransactionConfig({ + transaction: {}, + status: { amount, estimatedFees }, +}: { + account: AccountLike; + parentAccount: Account | null | undefined; + transaction: Transaction; + status: TransactionStatus; +}): Array { + const fields: Array = []; + + if (!amount.isZero()) { + fields.push({ + type: "amount", + label: "Amount", + }); + } + + if (!estimatedFees.isZero()) { + fields.push({ + type: "fees", + label: "Fees", + }); + } + + return fields; +} + +export default getDeviceTransactionConfig; diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/estimateMaxSpendable.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/estimateMaxSpendable.ts new file mode 100644 index 000000000000..5233ddb30d7b --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/estimateMaxSpendable.ts @@ -0,0 +1,25 @@ +import BigNumber from "bignumber.js"; +import { AccountBridge } from "@ledgerhq/types-live"; +import { getMainAccount } from "@ledgerhq/coin-framework/account/index"; +import { getAbandonSeedAddress } from "@ledgerhq/cryptoassets/abandonseed"; +import { getTransactionStatus } from "./getTransactionStatus"; +import { prepareTransaction } from "./prepareTransaction"; +import { createTransaction } from "./createTransaction"; +import { Transaction } from "../types"; + +export const estimateMaxSpendable: AccountBridge["estimateMaxSpendable"] = async ({ + account, + parentAccount, + transaction, +}) => { + const mainAccount = getMainAccount(account, parentAccount); + const newTransaction = await prepareTransaction(mainAccount, { + ...createTransaction(account), + ...transaction, + // fee estimation might require a recipient to work, in that case, we use a dummy one + recipient: transaction?.recipient || getAbandonSeedAddress("boilerplate"), + amount: new BigNumber(0), + }); + const status = await getTransactionStatus(mainAccount, newTransaction); + return BigNumber.max(0, account.spendableBalance.minus(status.estimatedFees)); +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/getTransactionStatus.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/getTransactionStatus.ts new file mode 100644 index 000000000000..2b079e7e62a6 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/getTransactionStatus.ts @@ -0,0 +1,88 @@ +import { + AmountRequired, + FeeNotLoaded, + FeeRequired, + FeeTooHigh, + InvalidAddress, + InvalidAddressBecauseDestinationIsAlsoSource, + NotEnoughBalanceBecauseDestinationNotCreated, + NotEnoughSpendableBalance, + RecipientRequired, +} from "@ledgerhq/errors"; +import BigNumber from "bignumber.js"; +import { Account, AccountBridge } from "@ledgerhq/types-live"; +import { formatCurrencyUnit } from "@ledgerhq/coin-framework/currencies/index"; +import { Transaction, TransactionStatus } from "../types"; +import { isRecipientValid } from "../common-logic/utils"; +import coinConfig from "../config"; + +export const getTransactionStatus: AccountBridge< + Transaction, + Account, + TransactionStatus +>["getTransactionStatus"] = async (account, transaction) => { + const errors: Record = {}; + const warnings: Record = {}; + + // reserveAmount is the minimum amount of currency that an account must hold in order to stay activated + const reserveAmount = new BigNumber(coinConfig.getCoinConfig().minReserve); + const estimatedFees = new BigNumber(transaction.fee || 0); + const totalSpent = new BigNumber(transaction.amount).plus(estimatedFees); + const amount = new BigNumber(transaction.amount); + + if (amount.gt(0) && estimatedFees.times(10).gt(amount)) { + // if the fee is more than 10 times the amount, we warn the user that fee is high compared to what he is sending + warnings.feeTooHigh = new FeeTooHigh(); + } + + if (!transaction.fee) { + // if the fee is not loaded, we can't do much + errors.fee = new FeeNotLoaded(); + } else if (transaction.fee.eq(0)) { + // On some chains, 0 fee could still work so this is optional + errors.fee = new FeeRequired(); + } else if (totalSpent.gt(account.balance.minus(reserveAmount))) { + // if the total spent is greater than the balance minus the reserve amount, tx is invalid + errors.amount = new NotEnoughSpendableBalance("", { + minimumAmount: formatCurrencyUnit(account.currency.units[0], reserveAmount, { + disableRounding: true, + useGrouping: false, + showCode: true, + }), + }); + } else if (transaction.recipient && transaction.amount.lt(reserveAmount)) { + // if we send an amount lower than reserve amount AND target account is new, we need to warn the user that the target account will not be activated + errors.amount = new NotEnoughBalanceBecauseDestinationNotCreated("", { + minimalAmount: formatCurrencyUnit(account.currency.units[0], reserveAmount, { + disableRounding: true, + useGrouping: false, + showCode: true, + }), + }); + } + + if (!transaction.recipient) { + errors.recipient = new RecipientRequired(""); + } else if (account.freshAddress === transaction.recipient) { + // we want to prevent user from sending to themselves (even if it's technically feasible) + errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource(); + } else if (!isRecipientValid(transaction.recipient)) { + // We want to prevent user from sending to an invalid address + errors.recipient = new InvalidAddress("", { + currencyName: account.currency.name, + }); + } + + if (!errors.amount && amount.eq(0)) { + // if the amount is 0, we prevent the user from sending the tx (even if it's technically feasible) + errors.amount = new AmountRequired(); + } + + return { + errors, + warnings, + estimatedFees, + amount, + totalSpent, + }; +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/index.test.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/index.test.ts new file mode 100644 index 000000000000..a98459cafc4a --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/index.test.ts @@ -0,0 +1,31 @@ +import { createBridges } from "."; + +describe("createBridges", () => { + it("should return both bridges interface", () => { + const bridges = createBridges(undefined as any, {} as any); + expect(bridges.accountBridge).toBeDefined(); + expect(bridges.currencyBridge).toBeDefined(); + }); + + it("should have a currency bridge with required methods", () => { + const bridges = createBridges(undefined as any, {} as any); + expect(bridges.currencyBridge).toBeDefined(); + expect(bridges.currencyBridge.preload).toBeDefined(); + expect(bridges.currencyBridge.hydrate).toBeDefined(); + expect(bridges.currencyBridge.scanAccounts).toBeDefined(); + }); + + it("should have an account bridge with required methods", () => { + const bridges = createBridges(undefined as any, {} as any); + expect(bridges.accountBridge).toBeDefined(); + expect(bridges.accountBridge.broadcast).toBeDefined(); + expect(bridges.accountBridge.createTransaction).toBeDefined(); + expect(bridges.accountBridge.estimateMaxSpendable).toBeDefined(); + expect(bridges.accountBridge.getTransactionStatus).toBeDefined(); + expect(bridges.accountBridge.prepareTransaction).toBeDefined(); + expect(bridges.accountBridge.receive).toBeDefined(); + expect(bridges.accountBridge.signOperation).toBeDefined(); + expect(bridges.accountBridge.sync).toBeDefined(); + expect(bridges.accountBridge.updateTransaction).toBeDefined(); + }); +}); diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/index.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/index.ts new file mode 100644 index 000000000000..c0ea3ea71822 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/index.ts @@ -0,0 +1,62 @@ +import getAddressWrapper from "@ledgerhq/coin-framework/bridge/getAddressWrapper"; +import { + getSerializedAddressParameters, + makeAccountBridgeReceive, + makeScanAccounts, + makeSync, +} from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import { CoinConfig } from "@ledgerhq/coin-framework/config"; +import { SignerContext } from "@ledgerhq/coin-framework/signer"; +import type { AccountBridge, CurrencyBridge } from "@ledgerhq/types-live"; +import boilerplateCoinConfig, { type BoilerplateCoinConfig } from "../config"; +import resolver from "../signer"; +import { BoilerplateSigner } from "../types"; +import type { Transaction } from "../types"; +import { broadcast } from "./broadcast"; +import { createTransaction } from "./createTransaction"; +import { estimateMaxSpendable } from "./estimateMaxSpendable"; +import { getTransactionStatus } from "./getTransactionStatus"; +import { prepareTransaction } from "./prepareTransaction"; +import { buildSignOperation } from "./signOperation"; +import { getAccountShape } from "./sync"; +import { updateTransaction } from "./updateTransaction"; + +export function createBridges( + signerContext: SignerContext, + coinConfig: CoinConfig, +) { + boilerplateCoinConfig.setCoinConfig(coinConfig); + + const getAddress = resolver(signerContext); + const receive = makeAccountBridgeReceive(getAddressWrapper(getAddress)); + + const scanAccounts = makeScanAccounts({ getAccountShape, getAddressFn: getAddress }); + const currencyBridge: CurrencyBridge = { + preload: () => Promise.resolve({}), + hydrate: () => {}, + scanAccounts, + }; + + const signOperation = buildSignOperation(signerContext); + const sync = makeSync({ getAccountShape }); + // we want one method per file + const accountBridge: AccountBridge = { + broadcast, + createTransaction, + updateTransaction, + // NOTE: use updateTransaction: defaultUpdateTransaction, + // if you don't need to update the transaction patch object + prepareTransaction, + getTransactionStatus, + estimateMaxSpendable, + sync, + receive, + signOperation, + getSerializedAddressParameters, + }; + + return { + currencyBridge, + accountBridge, + }; +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/prepareTransaction.test.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/prepareTransaction.test.ts new file mode 100644 index 000000000000..aa0dd56c5c72 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/prepareTransaction.test.ts @@ -0,0 +1,28 @@ +import BigNumber from "bignumber.js"; +import { craftTransaction, estimateFees } from "../common-logic"; +import { getNextSequence } from "../network/node"; +import { prepareTransaction } from "./prepareTransaction"; +import { Account } from "@ledgerhq/types-live"; +import { Transaction } from "../types"; + +jest.mock("../network/node"); +jest.mock("../common-logic"); + +describe("prepareTransaction", () => { + let estimateFeesSpy: jest.SpyInstance; + let getNextSequenceSpy: jest.SpyInstance; + let craftTransactionSpy: jest.SpyInstance; + beforeEach(() => { + getNextSequenceSpy = jest.spyOn({ getNextSequence }, "getNextSequence"); + estimateFeesSpy = jest.spyOn({ estimateFees }, "estimateFees"); + craftTransactionSpy = jest.spyOn({ craftTransaction }, "craftTransaction"); + craftTransactionSpy.mockReturnValue({ serializedTransaction: "serialized" }); + }); + + it("should update fee field if it's different", async () => { + const oldTx = { fee: new BigNumber(0) }; + estimateFeesSpy.mockResolvedValue(new BigNumber(1)); + const newTx = await prepareTransaction({} as Account, oldTx as Transaction); + expect(newTx.fee).toEqual(new BigNumber(1)); + }); +}); diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/prepareTransaction.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/prepareTransaction.ts new file mode 100644 index 000000000000..194af0961a4e --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/prepareTransaction.ts @@ -0,0 +1,24 @@ +import { AccountBridge } from "@ledgerhq/types-live"; +import { Transaction } from "../types"; +import { craftTransaction, estimateFees } from "../common-logic"; +import { getNextSequence } from "../network/node"; + +export const prepareTransaction: AccountBridge["prepareTransaction"] = async ( + account, + transaction, +) => { + const seq = await getNextSequence(account.freshAddress); + + const craftedTransaction = await craftTransaction( + { address: account.freshAddress, nextSequenceNumber: seq }, + { amount: transaction.amount, recipient: transaction.recipient }, + ); + + const fee = await estimateFees(craftedTransaction.serializedTransaction); + + if (transaction.fee !== fee) { + return { ...transaction, fee }; + } + + return transaction; +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/signOperation.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/signOperation.ts new file mode 100644 index 000000000000..5906088c2fd9 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/signOperation.ts @@ -0,0 +1,93 @@ +import { Observable } from "rxjs"; +import { FeeNotLoaded } from "@ledgerhq/errors"; +import { AccountBridge, Operation } from "@ledgerhq/types-live"; +import { SignerContext } from "@ledgerhq/coin-framework/signer"; +import { encodeOperationId } from "@ledgerhq/coin-framework/operation"; +import { combine, craftTransaction, getNextValidSequence } from "../common-logic"; +import { Transaction, BoilerplateSigner, BoilerplateNativeTransaction } from "../types"; + +export const buildSignOperation = + (signerContext: SignerContext): AccountBridge["signOperation"] => + ({ account, deviceId, transaction }) => + new Observable(o => { + async function main() { + const { fee } = transaction; + if (!fee) throw new FeeNotLoaded(); + + try { + // o observables allows to define steps of the signing process with the device + o.next({ + type: "device-signature-requested", + }); + + const nextSequenceNumber = await getNextValidSequence(account.freshAddress); + + const signature = await signerContext(deviceId, async signer => { + const { freshAddressPath: derivationPath } = account; + const { publicKey } = await signer.getAddress(derivationPath); + + const { nativeTransaction, serializedTransaction } = await craftTransaction( + { + address: account.freshAddress, + publicKey, + }, + { + recipient: transaction.recipient, + amount: transaction.amount, + fee: fee, + }, + ); + + const transactionSignature = await signer.signTransaction( + derivationPath, + serializedTransaction, + ); + + return combine(serializedTransaction, transactionSignature, publicKey); + }); + + o.next({ + type: "device-signature-granted", + }); + + // We create an optimistic operation here, the framework will then replace this transaction with the one returned by the indexer + const hash = ""; + const operation: Operation = { + id: encodeOperationId(account.id, hash, "OUT"), + hash, + accountId: account.id, + type: "OUT", + value: transaction.amount, + fee, + blockHash: null, + blockHeight: null, + senders: [account.freshAddress], + recipients: [transaction.recipient], + date: new Date(), + transactionSequenceNumber: nextSequenceNumber, + extra: {}, + }; + + o.next({ + type: "signed", + signedOperation: { + operation, + signature, + }, + }); + } catch (e) { + if (e instanceof Error) { + throw new Error( + (e as Error & { data?: { resultMessage?: string } })?.data?.resultMessage, + ); + } + + throw e; + } + } + + main().then( + () => o.complete(), + e => o.error(e), + ); + }); diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/sync.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/sync.ts new file mode 100644 index 000000000000..3ed2bd268721 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/sync.ts @@ -0,0 +1,106 @@ +import BigNumber from "bignumber.js"; +import { Operation } from "@ledgerhq/types-live"; +import { encodeAccountId } from "@ledgerhq/coin-framework/account/index"; +import { GetAccountShape, mergeOps } from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import { getTransactions } from "../network/indexer"; +import { getAccountInfo, getBlockHeight } from "../network/node"; + +import { encodeOperationId } from "@ledgerhq/coin-framework/operation"; +import { BoilerplateOperation } from "../network/types"; +import coinConfig from "../config"; + +const operationAdapter = + (accountId: string, address: string) => + ({ + meta: { delivered_amount }, + tx: { Fee, hash, inLedger, date, Account, Destination, Sequence }, + }: BoilerplateOperation) => { + const type = Account === address ? "OUT" : "IN"; + let value = + delivered_amount && typeof delivered_amount === "string" + ? new BigNumber(delivered_amount) + : new BigNumber(0); + const feeValue = new BigNumber(Fee); + + if (type === "OUT") { + if (!Number.isNaN(feeValue)) { + value = value.plus(feeValue); + } + } + + const op: Operation = { + id: encodeOperationId(accountId, hash, type), + hash: hash, + accountId, + type, + value, + fee: feeValue, + blockHash: null, + blockHeight: inLedger, + senders: [Account], + recipients: [Destination], + date: new Date(), + transactionSequenceNumber: Sequence, + extra: {}, + }; + + return op; + }; + +const filterOperations = ( + transactions: BoilerplateOperation[], + accountId: string, + address: string, +) => { + return transactions + .filter( + ({ tx, meta }: BoilerplateOperation) => + tx.TransactionType === "Payment" && typeof meta.delivered_amount === "string", + ) + .map(operationAdapter(accountId, address)) + .filter((op): op is Operation => Boolean(op)); +}; + +export const getAccountShape: GetAccountShape = async info => { + const { address, initialAccount, currency, derivationMode } = info; + + const accountId = encodeAccountId({ + type: "js", + version: "2", + currencyId: currency.id, + xpubOrAddress: address, + derivationMode, + }); + + // blockheight retrieval + const blockHeight = await getBlockHeight(); + + // Account info retrieval + spendable balance calculation + const accountInfo = await getAccountInfo(address); + const balance = new BigNumber(accountInfo.account_data.Balance); + const reserveMin = coinConfig.getCoinConfig().minReserve; + const spendableBalance = new BigNumber(accountInfo.account_data.Balance).minus(reserveMin); + + // Tx history fetching + const oldOperations = initialAccount?.operations || []; + const startAt = oldOperations.length ? (oldOperations[0].blockHeight || 0) + 1 : 0; + const newTransactions = await getTransactions(address, { + from: startAt, + size: 100, + }); + const newOperations = filterOperations(newTransactions, accountId, address); + const operations = mergeOps(oldOperations, newOperations as Operation[]); + + // We return the new account shape + const shape = { + id: accountId, + xpub: address, + blockHeight, + balance, + spendableBalance, + operations, + operationsCount: operations.length, + }; + + return shape; +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/transaction.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/transaction.ts new file mode 100644 index 000000000000..076bbcd87699 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/transaction.ts @@ -0,0 +1,61 @@ +import { BigNumber } from "bignumber.js"; +import type { Transaction, TransactionRaw } from "../types"; +import { formatTransactionStatus } from "@ledgerhq/coin-framework/formatters"; +import { + fromTransactionCommonRaw, + fromTransactionStatusRawCommon as fromTransactionStatusRaw, + toTransactionCommonRaw, + toTransactionStatusRawCommon as toTransactionStatusRaw, +} from "@ledgerhq/coin-framework/serialization/transaction"; +import type { Account } from "@ledgerhq/types-live"; +import { getAccountCurrency } from "@ledgerhq/coin-framework/account/index"; +import { formatCurrencyUnit } from "@ledgerhq/coin-framework/currencies/index"; + +export const formatTransaction = ( + { amount, recipient, fee, useAllAmount }: Transaction, + account: Account, +): string => ` +SEND ${ + useAllAmount + ? "MAX" + : formatCurrencyUnit(getAccountCurrency(account).units[0], amount, { + showCode: true, + disableRounding: true, + }) +} +TO ${recipient} +with fee=${ + !fee + ? "?" + : formatCurrencyUnit(getAccountCurrency(account).units[0], fee, { + showCode: true, + disableRounding: true, + }) +}`; + +export const fromTransactionRaw = (tr: TransactionRaw): Transaction => { + const common = fromTransactionCommonRaw(tr); + return { + ...common, + family: tr.family, + fee: tr.fee ? new BigNumber(tr.fee) : null, + }; +}; + +export const toTransactionRaw = (t: Transaction): TransactionRaw => { + const common = toTransactionCommonRaw(t); + return { + ...common, + family: t.family, + fee: t.fee ? t.fee.toString() : null, + }; +}; + +export default { + formatTransaction, + fromTransactionRaw, + toTransactionRaw, + fromTransactionStatusRaw, + toTransactionStatusRaw, + formatTransactionStatus, +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/bridge/updateTransaction.ts b/libs/coin-modules/coin-module-boilerplate/src/bridge/updateTransaction.ts new file mode 100644 index 000000000000..e18084cebf9e --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/bridge/updateTransaction.ts @@ -0,0 +1,17 @@ +import { updateTransaction as defaultUpdateTransaction } from "@ledgerhq/coin-framework/bridge/jsHelpers"; +import { AccountBridge } from "@ledgerhq/types-live"; +import type { Transaction } from "../types"; + +// NOTE: this method is optional, use defaultUpdateTransaction +// acts as a middleware to update the transaction patch object + +// NOTE: here is an example transaction updater function +// in this case, it resets fee to null depending on the patch content +export const updateTransaction: AccountBridge["updateTransaction"] = (tx, patch) => { + // eslint-disable-next-line no-constant-condition + if (patch.recipient === "boilerplate1" || true) { + patch = { ...patch, fee: null }; + } + + return defaultUpdateTransaction(tx, patch); +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/account/getBalance.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/account/getBalance.ts new file mode 100644 index 000000000000..673f7c395a97 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/account/getBalance.ts @@ -0,0 +1,7 @@ +import { getAccountInfo } from "../../network/node"; + +// Could be getAccountInfo so it is used in both bridge and api +export async function getBalance(address: string): Promise { + const accountInfo = await getAccountInfo(address); + return BigInt(accountInfo.account_data.Balance); +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/account/getNextSequence.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/account/getNextSequence.ts new file mode 100644 index 000000000000..9cbfd435fbec --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/account/getNextSequence.ts @@ -0,0 +1,6 @@ +import { getNextSequence } from "../../network/node"; + +// Could be getAccountInfo so it is used in both bridge and api +export async function getNextValidSequence(address: string): Promise { + return await getNextSequence(address); +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/common.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/common.ts new file mode 100644 index 000000000000..55f40c08f1aa --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/common.ts @@ -0,0 +1,9 @@ +import { BigNumber } from "bignumber.js"; +import { parseCurrencyUnit } from "@ledgerhq/coin-framework/currencies"; +import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; + +// NOTE: replace ripple by your currency id, it should be found in +// libs/ledgerjs/packages/cryptoassets/src/currencies.ts +const boilerplateUnit = getCryptoCurrencyById("ripple").units[0]; +export const parseAPIValue = (value: string): BigNumber => + parseCurrencyUnit(boilerplateUnit, value); diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/history/lastBlock.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/history/lastBlock.ts new file mode 100644 index 000000000000..c4b57f8947c8 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/history/lastBlock.ts @@ -0,0 +1,11 @@ +import type { BlockInfo } from "@ledgerhq/coin-framework/api/index"; +import { getLastBlock } from "../../network/node"; + +export async function lastBlock(): Promise { + const result = await getLastBlock(); + return { + height: result.blockHeight, + hash: result.blockHash, + time: new Date(result.timestamp), + }; +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/history/listOperations.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/history/listOperations.ts new file mode 100644 index 000000000000..5cfd7b3ef983 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/history/listOperations.ts @@ -0,0 +1,54 @@ +import type { Operation, Pagination } from "@ledgerhq/coin-framework/api/index"; +import { getTransactions } from "../../network/indexer"; + +/** + * Returns list of operations associated to an account. + * @param address Account address + * @param pagination Pagination options + * @returns Operations found and the next "id" or "index" to use for pagination (i.e. `start` property).\ + * If `0` is returns, no pagination needed. + * This "id" or "index" value, thus it has functional meaning, is different for each blockchain. + */ +export async function listOperations( + address: string, + { limit, start }: Pagination, +): Promise<[Operation[], number]> { + const transactions = await getTransactions(address, { from: start || 0, size: limit }); + + return [transactions.map(convertToCoreOperation(address)), transactions.length]; +} + +const convertToCoreOperation = (address: string) => (operation: any) => { + const { + meta: { delivered_amount }, + tx: { Fee, hash, inLedger, date, Account, Destination, Sequence }, + } = operation; + + const type = Account === address ? "OUT" : "IN"; + let value = + delivered_amount && typeof delivered_amount === "string" ? BigInt(delivered_amount) : BigInt(0); + + const feeValue = BigInt(Fee); + if (type === "OUT") { + if (!Number.isNaN(feeValue)) { + value = value + feeValue; + } + } + + return { + hash, + address, + type, + value, + fee: feeValue, + block: { + height: inLedger, + hash, + time: date, + }, + senders: [Account], + recipients: [Destination], + date: new Date(date), + transactionSequenceNumber: Sequence, + }; +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/index.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/index.ts new file mode 100644 index 000000000000..04a1a1f86972 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/index.ts @@ -0,0 +1,11 @@ +export { broadcast } from "./transaction/broadcast"; +export { combine } from "./transaction/combine"; +export { craftTransaction } from "./transaction/craftTransaction"; +export { estimateFees } from "./transaction/estimateFees"; +export { getBalance } from "./account/getBalance"; +export { lastBlock } from "./history/lastBlock"; +export { listOperations } from "./history/listOperations"; +export { isRecipientValid } from "./utils"; +export { getNextValidSequence } from "./account/getNextSequence"; + +export { parseAPIValue } from "./common"; diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/broadcast.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/broadcast.ts new file mode 100644 index 000000000000..efed824bfea4 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/broadcast.ts @@ -0,0 +1,6 @@ +import { submit } from "../../network/node"; + +export async function broadcast(signedTx: string): Promise { + const submittedPayment = await submit(signedTx); + return submittedPayment.tx_hash; +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/combine.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/combine.ts new file mode 100644 index 000000000000..730400f44c9a --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/combine.ts @@ -0,0 +1,6 @@ +import { encode } from "../utils"; + +// Combines signature with raw transaction +export function combine(transaction: string, signature: string, publicKey?: string): string { + return encode(transaction, signature, publicKey || ""); +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/craftTransaction.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/craftTransaction.ts new file mode 100644 index 000000000000..05d84a5ab6fd --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/craftTransaction.ts @@ -0,0 +1,36 @@ +import BigNumber from "bignumber.js"; +import { BoilerplateNativeTransaction } from "../../types"; + +const encodeNativeTx = (nativeTx: BoilerplateNativeTransaction) => JSON.stringify(nativeTx); + +export async function craftTransaction( + account: { + address: string; + nextSequenceNumber?: number; + publicKey?: string; + }, + transaction: { + recipient?: string; + amount: BigNumber; + fee?: BigNumber; + }, +): Promise<{ + nativeTransaction: BoilerplateNativeTransaction; + serializedTransaction: string; +}> { + const nativeTransaction: BoilerplateNativeTransaction = { + TransactionType: "Payment", + Account: account.address, + Amount: transaction.amount.toString(), + Destination: transaction.recipient || "", + Fee: transaction.fee?.toString() || "0", + Sequence: account.nextSequenceNumber || 0, + }; + + const serializedTransaction = encodeNativeTx(nativeTransaction); + + return { + nativeTransaction, + serializedTransaction, + }; +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/estimateFees.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/estimateFees.ts new file mode 100644 index 000000000000..0e39b0265792 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/transaction/estimateFees.ts @@ -0,0 +1,19 @@ +import BigNumber from "bignumber.js"; +import { simulate } from "../../network/node"; +import { SimulationError } from "../../types/errors"; + +export async function estimateFees(serializedTx: string): Promise { + let fees; + try { + // We call the node to do a dry run and estimate fees + fees = await simulate(serializedTx); + } catch (e) { + // default value is required in case of simulation error, else user will encounter an error in the flow + if (e instanceof SimulationError) { + fees = new BigNumber(1000); + } else { + throw new Error("Unexpected error while estimating fees."); + } + } + return new BigNumber(fees); +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/common-logic/utils.ts b/libs/coin-modules/coin-module-boilerplate/src/common-logic/utils.ts new file mode 100644 index 000000000000..aae2ea28060b --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/common-logic/utils.ts @@ -0,0 +1,18 @@ +import BigNumber from "bignumber.js"; + +export const UINT32_MAX = new BigNumber(2).pow(32).minus(1); + +export const validateTag = (tag: BigNumber) => { + return ( + !tag.isNaN() && tag.isFinite() && tag.isInteger() && tag.isPositive() && tag.lte(UINT32_MAX) + ); +}; + +export function isRecipientValid(recipient: string): boolean { + return recipient.length > 0; +} + +export const encode = (transaction: string, signature: string, publicKey?: string) => { + // sample encoding + return `${transaction}${publicKey}${signature}encodedTx`; +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/config.ts b/libs/coin-modules/coin-module-boilerplate/src/config.ts new file mode 100644 index 000000000000..a174aaf4b220 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/config.ts @@ -0,0 +1,12 @@ +import buildConConfig, { type CurrencyConfig } from "@ledgerhq/coin-framework/config"; + +export type BoilerplateConfig = { + nodeUrl: string; + minReserve: number; +}; + +export type BoilerplateCoinConfig = CurrencyConfig & BoilerplateConfig; + +const coinConfig = buildConConfig(); + +export default coinConfig; diff --git a/libs/coin-modules/coin-module-boilerplate/src/index.ts b/libs/coin-modules/coin-module-boilerplate/src/index.ts new file mode 100644 index 000000000000..eaf15bd02113 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; + +export { createBridges } from "./bridge/index"; +export type { BoilerplateCoinConfig } from "./config"; diff --git a/libs/coin-modules/coin-module-boilerplate/src/network/indexer.ts b/libs/coin-modules/coin-module-boilerplate/src/network/indexer.ts new file mode 100644 index 000000000000..07413e7ccfee --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/network/indexer.ts @@ -0,0 +1,17 @@ +import { AccountTxResponse } from "./types"; +import network from "@ledgerhq/live-network/network"; +import { getEnv } from "@ledgerhq/live-env"; + +export const getTransactions = async ( + address: string, + params: { from: number; size: number }, +): Promise => { + const { data } = await network({ + // NOTE: add INDEXER_BOILERPLATE to libs/env/src/env.ts + // @ts-expect-error: add INDEXER_BOILERPLATE to libs/env/src/env.ts + url: `${getEnv("INDEXER_BOILERPLATE")}/account/${address}/transactions`, + method: "GET", + }); + + return data.transactions; +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/network/mock-network.ts b/libs/coin-modules/coin-module-boilerplate/src/network/mock-network.ts new file mode 100644 index 000000000000..db9309476bd1 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/network/mock-network.ts @@ -0,0 +1,25 @@ +/* + +In a real use case you should use live-network library like this : + +import network from "@ledgerhq/live-network/network"; + + +instead of this mocked method + +*/ + +export const network = (params: { url: string; method: "GET" | "POST" }) => { + switch (true) { + case params.url.includes("simulate"): + break; + case params.url.includes("submit"): + break; + case params.url.includes("account_info"): + break; + case params.url.includes("transactions"): + break; + default: + throw new Error("Mock network 404"); + } +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/network/node.ts b/libs/coin-modules/coin-module-boilerplate/src/network/node.ts new file mode 100644 index 000000000000..5c5034460d64 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/network/node.ts @@ -0,0 +1,91 @@ +import { SimulationError } from "../types/errors"; +import network from "@ledgerhq/live-network/network"; +import { getEnv } from "@ledgerhq/live-env"; +import coinConfig from "../config"; +import { AccountInfoResponse, SubmitReponse } from "./types"; + +const getNodeUrl = () => coinConfig.getCoinConfig().nodeUrl; + +// NOTE: add NODE_BOILERPLATE to libs/env/src/env.ts + +// txPayload needs to be unsigned +export const simulate = async (serializedTx: string): Promise => { + // @ts-expect-error: add NODE_BOILERPLATE to libs/env/src/env.ts + const url = `${getEnv("NODE_BOILERPLATE")}/simulate`; + const { data } = await network({ + url, + method: "GET", + }); + if (data.error) { + throw new SimulationError(); + } + return data.fees; +}; + +// can be called nonce or sequence +export const getNextSequence = async (address: string): Promise => { + // @ts-expect-error: add NODE_BOILERPLATE to libs/env/src/env.ts + const url = `${getEnv("NODE_BOILERPLATE")}/${address}/sequence`; + try { + const { data } = await network({ + url, + method: "GET", + }); + return data.sequence; + } catch (e) { + return 0; + } +}; + +export const getBlockHeight = async (): Promise => { + // @ts-expect-error: add NODE_BOILERPLATE to libs/env/src/env.ts + const url = `${getEnv("NODE_BOILERPLATE")}/blockheight`; + const { data } = await network({ + url, + method: "GET", + }); + return data.blockHeight; +}; + +export const getLastBlock = async (): Promise<{ + blockHeight: number; + blockHash: string; + timestamp: number; +}> => { + // @ts-expect-error: add NODE_BOILERPLATE to libs/env/src/env.ts + const url = `${getEnv("NODE_BOILERPLATE")}/block/current`; + const { data } = await network({ + url, + method: "GET", + }); + return data; +}; + +export const submit = async (signedTx: string): Promise => { + // @ts-expect-error: add NODE_BOILERPLATE to libs/env/src/env.ts + const url = `${getEnv("NODE_BOILERPLATE")}/submit`; + const { data } = await network({ + url, + method: "GET", + }); + return data; +}; + +export const getAccountInfo = async (address: string): Promise => { + const { + data: { result }, + } = await network<{ result: AccountInfoResponse }>({ + method: "POST", + url: getNodeUrl(), + data: { + method: "account_info", + params: [ + { + account: address, + }, + ], + }, + }); + + return result; +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/network/types.ts b/libs/coin-modules/coin-module-boilerplate/src/network/types.ts new file mode 100644 index 000000000000..abb03a353f2f --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/network/types.ts @@ -0,0 +1,51 @@ +export type BoilerplateOperation = { + meta: { + delivered_amount: string; + }; + tx: { + Account: string; + Amount: string; + Destination: string; + Fee: string; + Memo: string; + Sequence: number; + SigningPubKey: string; + TransactionType: string; + TxnSignature: string; + date: number; + hash: string; + inLedger: number; + }; + validated: boolean; +}; + +export type ResponseStatus = + | { status: string; error?: never } + | { + status?: never; + error: string; + }; +export function isResponseStatus(obj: object): obj is ResponseStatus { + return "status" in obj || "error" in obj; +} + +export type NewAccount = "NewAccount"; +export type AccountInfoResponse = { + account_data: { + Account: string; + Balance: string; + }; + ledger_hash: string; + ledger_index: number; + validated: boolean; +} & ResponseStatus; + +export type SubmitReponse = { + accepted: boolean; + tx_hash: string; +}; + +export type AccountTxResponse = { + account: string; + transactions: BoilerplateOperation[]; +} & ResponseStatus; diff --git a/libs/coin-modules/coin-module-boilerplate/src/signer/getAddress.ts b/libs/coin-modules/coin-module-boilerplate/src/signer/getAddress.ts new file mode 100644 index 000000000000..717b759d8ac8 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/signer/getAddress.ts @@ -0,0 +1,20 @@ +import { GetAddressOptions } from "@ledgerhq/coin-framework/derivation"; +import { GetAddressFn } from "@ledgerhq/coin-framework/bridge/getAddressWrapper"; +import { SignerContext } from "@ledgerhq/coin-framework/signer"; +import { BOilerplateAddress, BoilerplateSigner } from "../types"; + +const getAddress = (signerContext: SignerContext): GetAddressFn => { + return async (deviceId: string, { path, verify }: GetAddressOptions) => { + const { address, publicKey } = (await signerContext(deviceId, signer => + signer.getAddress(path), + )) as BOilerplateAddress; + + return { + path, + address, + publicKey, + }; + }; +}; + +export default getAddress; diff --git a/libs/coin-modules/coin-module-boilerplate/src/signer/index.ts b/libs/coin-modules/coin-module-boilerplate/src/signer/index.ts new file mode 100644 index 000000000000..1f3f13571b7e --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/signer/index.ts @@ -0,0 +1,7 @@ +/** + * This directory is the home for all types and logic based on Ledgers signer. + */ + +import getAddress from "./getAddress"; + +export default getAddress; diff --git a/libs/coin-modules/coin-module-boilerplate/src/test/bot-deviceActions.ts b/libs/coin-modules/coin-module-boilerplate/src/test/bot-deviceActions.ts new file mode 100644 index 000000000000..b0f648d31119 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/test/bot-deviceActions.ts @@ -0,0 +1,48 @@ +import type { DeviceAction } from "@ledgerhq/coin-framework/bot/types"; +import type { Transaction } from "../types"; +import { + deviceActionFlow, + formatDeviceAmount, + SpeculosButton, +} from "@ledgerhq/coin-framework/bot/specs"; + +export const acceptTransaction: DeviceAction = deviceActionFlow({ + steps: [ + { + title: "Transaction Type", + button: SpeculosButton.RIGHT, + expectedValue: () => "Payment", + }, + { + title: "Amount", + button: SpeculosButton.RIGHT, + expectedValue: ({ account, status }) => formatDeviceAmount(account.currency, status.amount), + }, + { + title: "Fee", + button: SpeculosButton.RIGHT, + expectedValue: ({ account, status }) => + formatDeviceAmount(account.currency, status.estimatedFees), + }, + { + title: "Destination", + button: SpeculosButton.RIGHT, + trimValue: true, + expectedValue: ({ transaction }) => transaction.recipient, + }, + { + title: "Account", + button: SpeculosButton.RIGHT, + trimValue: true, + expectedValue: ({ account }) => account.freshAddress, + }, + { + title: "Accept", + button: SpeculosButton.BOTH, + }, + { + title: "Sign transaction", + button: SpeculosButton.BOTH, + }, + ], +}); diff --git a/libs/coin-modules/coin-module-boilerplate/src/test/bot-specs.ts b/libs/coin-modules/coin-module-boilerplate/src/test/bot-specs.ts new file mode 100644 index 000000000000..03fcc077d84d --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/test/bot-specs.ts @@ -0,0 +1,76 @@ +/* + +import invariant from "invariant"; +import expect from "expect"; +import { DeviceModelId } from "@ledgerhq/devices"; +import type { AppSpec } from "@ledgerhq/coin-framework/bot/types"; +import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets/currencies"; +import { parseCurrencyUnit } from "@ledgerhq/coin-framework/currencies/index"; +import { botTest, genericTestDestination, pickSiblings } from "@ledgerhq/coin-framework/bot/specs"; +import { acceptTransaction } from "./bot-deviceActions"; +import type { Transaction } from "../types"; + +const currency = getCryptoCurrencyById("boilerplate"); +const minAmountCutoff = parseCurrencyUnit(currency.units[0], "0.1"); +const reserve = parseCurrencyUnit(currency.units[0], "20"); + +const boilerplateSpec: AppSpec = { + name: "BOILERPLATE", + currency, + appQuery: { + model: DeviceModelId.nanoS, + appName: "BOILERPLATE_APP_NAME", + }, + genericDeviceAction: acceptTransaction, + minViableAmount: minAmountCutoff, + mutations: [ + { + name: "move ~50%", + maxRun: 2, + testDestination: genericTestDestination, + transaction: ({ account, siblings, bridge, maxSpendable }) => { + invariant(maxSpendable.gt(minAmountCutoff), "balance is too low"); + const transaction = bridge.createTransaction(account); + const sibling = pickSiblings(siblings, 3); + const recipient = sibling.freshAddress; + let amount = maxSpendable.div(1.9 + 0.2 * Math.random()).integerValue(); + + if (!sibling.used && amount.lt(reserve)) { + invariant( + maxSpendable.gt(reserve.plus(minAmountCutoff)), + "not enough funds to send to new account", + ); + amount = reserve; + } + + return { + transaction, + updates: [ + { + amount, + }, + { + recipient, + }, + Math.random() > 0.5 + ? { + tag: 123, + } + : null, + ], + }; + }, + test: ({ account, accountBeforeTransaction, operation }) => { + botTest("account balance moved with operation.value", () => + expect(account.balance.toString()).toBe( + accountBeforeTransaction.balance.minus(operation.value).toString(), + ), + ); + }, + }, + ], +}; +export default { + boilerplateSpec, +}; +*/ diff --git a/libs/coin-modules/coin-module-boilerplate/src/test/bridgeDatasetTest.ts b/libs/coin-modules/coin-module-boilerplate/src/test/bridgeDatasetTest.ts new file mode 100644 index 000000000000..0340d60ba21d --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/test/bridgeDatasetTest.ts @@ -0,0 +1,147 @@ +import BigNumber from "bignumber.js"; +import { DatasetTest } from "@ledgerhq/types-live"; +import { InvalidAddressBecauseDestinationIsAlsoSource } from "@ledgerhq/errors"; +import { fromTransactionRaw } from "../bridge/transaction"; +import { Transaction } from "../types"; + +export const newAddress1 = "rZvBc5e2YR1A9otS3r9DyGh3NDP8XLLp4"; + +export const dataset: DatasetTest = { + implementations: ["mock", "ripplejs"], + currencies: { + ripple: { + scanAccounts: [ + { + name: "ripple seed 1", + unstableAccounts: true, + // our account is getting spammed... + apdus: ` + => e00200400d038000002c8000009080000000 + <= 2103c73f64083463fa923e1530af6f558204853873c6a45cbfb1f2f1e2ac2a5d989c2272734a4675764165634c333153513750594864504b6b3335625a456f78446d5231789000 + => e002004015058000002c80000090800000000000000000000000 + <= 2103d1adcff3e0cf1232b1416a75cd6f23b49dd6a25c69bc291a1f6783ec6825ec062272616765584842365134566276765764547a4b414e776a65435434485846434b58379000 + => e002004015058000002c80000090800000010000000000000000 + <= 21036da109ee84825eab0f55fb57bcf9ef0b05621e71fb0400266fb42d6f68f9487c2272425065393169766d67384347573450414e6f657555555173756d337470786a55469000 + => e002004015058000002c80000090800000020000000000000000 + <= 2102df9a55b79fb3668dac70fee7372806195841cd713ab8da9fba82240f9db8a23921725a76426335653259523141396f745333723944794768334e445038584c4c70349000 + `, + }, + ], + accounts: [ + { + transactions: [ + // FIXME + + /* + { + name: "not enough spendable balance with base reserve", + transaction: fromTransactionRaw({ + family: "xrp", + recipient: "rB6pwovsyrFWhPYUsjj9V3CHck985QjiXi", + amount: "15000000", + tag: null, + fee: "1", + feeCustomUnit: null, + networkInfo: null, + }), + expectedStatus: { + amount: BigNumber("15000000"), + estimatedFees: BigNumber("1"), + errors: { + amount: new NotEnoughSpendableBalance(null, { + minimumAmount: formatCurrencyUnit( + rippleUnit, + BigNumber("20"), + { + disableRounding: true, + useGrouping: false, + showCode: true, + } + ), + }), + }, + warnings: {}, + totalSpent: BigNumber("15000001"), + }, + }, + */ + // FIXME + + /* + { + name: "operation amount to low to create the recipient account", + transaction: fromTransactionRaw({ + family: "xrp", + recipient: newAddress1, + amount: "10000000", + tag: null, + fee: "1", + feeCustomUnit: null, + networkInfo: null + }), + expectedStatus: { + amount: BigNumber("10000000"), + estimatedFees: BigNumber("1"), + errors: { + amount: new NotEnoughBalanceBecauseDestinationNotCreated() + }, + warnings: {}, + totalSpent: BigNumber("10000001") + } + }, + */ + { + name: "recipient and sender must not be the same", + transaction: fromTransactionRaw({ + family: "boilerplate", + recipient: "rageXHB6Q4VbvvWdTzKANwjeCT4HXFCKX7", + amount: "10000000", + fee: "1", + }), + expectedStatus: { + amount: new BigNumber("10000000"), + estimatedFees: new BigNumber("1"), + errors: { + recipient: new InvalidAddressBecauseDestinationIsAlsoSource(), + }, + warnings: {}, + totalSpent: new BigNumber("10000001"), + }, + }, + { + name: "Operation with tag succeed", + transaction: fromTransactionRaw({ + family: "boilerplate", + recipient: "rB6pwovsyrFWhPYUsjj9V3CHck985QjiXi", + amount: "10000000", + fee: "1", + }), + expectedStatus: { + amount: new BigNumber("10000000"), + estimatedFees: new BigNumber("1"), + errors: {}, + warnings: {}, + totalSpent: new BigNumber("10000001"), + }, + }, + ], + raw: { + id: "ripplejs:2:ripple:rageXHB6Q4VbvvWdTzKANwjeCT4HXFCKX7:", + seedIdentifier: "rageXHB6Q4VbvvWdTzKANwjeCT4HXFCKX7", + name: "XRP 1", + derivationMode: "", + index: 0, + freshAddress: "rageXHB6Q4VbvvWdTzKANwjeCT4HXFCKX7", + freshAddressPath: "44'/144'/0'/0/0", + blockHeight: 0, + operations: [], + pendingOperations: [], + currencyId: "ripple", + lastSyncDate: "", + balance: "21000310", + }, + }, + ], + }, + }, +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/test/cli.ts b/libs/coin-modules/coin-module-boilerplate/src/test/cli.ts new file mode 100644 index 000000000000..ed8df3abd8a0 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/test/cli.ts @@ -0,0 +1,44 @@ +import invariant from "invariant"; +import type { AccountLike } from "@ledgerhq/types-live"; +import type { Transaction } from "../types"; +import BigNumber from "bignumber.js"; + +const options = [ + { + name: "fee", + type: String, + desc: "how much fee", + }, + { + name: "tag", + type: Number, + desc: "ripple tag", + }, +]; + +function inferTransactions( + transactions: Array<{ + account: AccountLike; + transaction: Transaction; + }>, + opts: { tag?: number | null | undefined; fee?: string }, + { + inferAmount, + }: { inferAmount: (account: AccountLike, fee?: string) => BigNumber | null | undefined }, +): Transaction[] { + return transactions.flatMap(({ transaction, account }) => { + invariant(transaction.family === "boilerplate", "Boilerplate family"); + return { + ...transaction, + fee: inferAmount(account, opts.fee || "0.001brp"), + tag: opts.tag, + }; + }); +} + +export default function makeCliTools() { + return { + options, + inferTransactions, + }; +} diff --git a/libs/coin-modules/coin-module-boilerplate/src/test/index.ts b/libs/coin-modules/coin-module-boilerplate/src/test/index.ts new file mode 100644 index 000000000000..ba9bf340d77c --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/test/index.ts @@ -0,0 +1,6 @@ +import makeCliTools from "./cli"; + +export * from "./bridgeDatasetTest"; +export { makeCliTools }; +export * from "./bot-deviceActions"; +// export * from "./bot-specs"; diff --git a/libs/coin-modules/coin-module-boilerplate/src/types/bridge.ts b/libs/coin-modules/coin-module-boilerplate/src/types/bridge.ts new file mode 100644 index 000000000000..7c190a59bfa0 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/types/bridge.ts @@ -0,0 +1,33 @@ +import type { BigNumber } from "bignumber.js"; +import type { Unit } from "@ledgerhq/types-cryptoassets"; +import type { + TransactionCommon, + TransactionCommonRaw, + TransactionStatusCommon, + TransactionStatusCommonRaw, +} from "@ledgerhq/types-live"; + +export type NetworkInfo = { + family: "boilerplate"; + serverFee: BigNumber; + baseReserve: BigNumber; +}; + +export type NetworkInfoRaw = { + family: "boilerplate"; + serverFee: string; + baseReserve: string; +}; + +export type Transaction = TransactionCommon & { + family: "boilerplate"; + fee: BigNumber | null | undefined; +}; + +export type TransactionRaw = TransactionCommonRaw & { + family: "boilerplate"; + fee: string | null | undefined; +}; + +export type TransactionStatus = TransactionStatusCommon; +export type TransactionStatusRaw = TransactionStatusCommonRaw; diff --git a/libs/coin-modules/coin-module-boilerplate/src/types/errors.ts b/libs/coin-modules/coin-module-boilerplate/src/types/errors.ts new file mode 100644 index 000000000000..2a7637d9a875 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/types/errors.ts @@ -0,0 +1,3 @@ +import { createCustomErrorClass } from "@ledgerhq/errors"; + +export const SimulationError = createCustomErrorClass("SimulationError"); diff --git a/libs/coin-modules/coin-module-boilerplate/src/types/index.ts b/libs/coin-modules/coin-module-boilerplate/src/types/index.ts new file mode 100644 index 000000000000..910db70c8bc0 --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/types/index.ts @@ -0,0 +1,13 @@ +export * from "./bridge"; +export * from "./signer"; + +export type BoilerplateNativeTransaction = { + TransactionType: "Payment"; + Account: string; + Amount: string; + Destination: string; + Fee: string; + Sequence: number; + SigningPubKey?: string; + TxnSignature?: string; +}; diff --git a/libs/coin-modules/coin-module-boilerplate/src/types/signer.ts b/libs/coin-modules/coin-module-boilerplate/src/types/signer.ts new file mode 100644 index 000000000000..1a60f33d0fbf --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/src/types/signer.ts @@ -0,0 +1,11 @@ +export type BOilerplateAddress = { + publicKey: string; + address: string; +}; + +export type BoilerplateSignature = string; // `0x${string}` + +export interface BoilerplateSigner { + getAddress(path: string): Promise; + signTransaction(path: string, rawTx: string): Promise; +} diff --git a/libs/coin-modules/coin-module-boilerplate/tsconfig.json b/libs/coin-modules/coin-module-boilerplate/tsconfig.json new file mode 100644 index 000000000000..f56b01ccd35e --- /dev/null +++ b/libs/coin-modules/coin-module-boilerplate/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "module": "commonjs", + "downlevelIteration": true, + "lib": ["es2020", "dom"], + "outDir": "lib", + "exactOptionalPropertyTypes": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa08803f354e..6a8a7be68e9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2662,6 +2662,64 @@ importers: specifier: ^29.1.1 version: 29.1.5(jest@29.7.0)(typescript@5.4.3) + libs/coin-modules/coin-module-boilerplate: + dependencies: + '@ledgerhq/coin-framework': + specifier: workspace:^ + version: link:../../coin-framework + '@ledgerhq/cryptoassets': + specifier: workspace:^ + version: link:../../ledgerjs/packages/cryptoassets + '@ledgerhq/devices': + specifier: workspace:* + version: link:../../ledgerjs/packages/devices + '@ledgerhq/errors': + specifier: workspace:^ + version: link:../../ledgerjs/packages/errors + '@ledgerhq/live-env': + specifier: workspace:^ + version: link:../../env + '@ledgerhq/live-network': + specifier: workspace:^ + version: link:../../live-network + '@ledgerhq/types-live': + specifier: workspace:^ + version: link:../../ledgerjs/packages/types-live + bignumber.js: + specifier: ^9.1.2 + version: 9.1.2 + invariant: + specifier: ^2.2.4 + version: 2.2.4 + rxjs: + specifier: ^7.8.1 + version: 7.8.1 + devDependencies: + '@ledgerhq/types-cryptoassets': + specifier: workspace:^ + version: link:../../ledgerjs/packages/types-cryptoassets + '@types/invariant': + specifier: ^2.2.37 + version: 2.2.37 + '@types/jest': + specifier: ^29.5.12 + version: 29.5.12 + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + expect: + specifier: ^27.4.6 + version: 27.5.1 + jest: + specifier: ^29.7.0 + version: 29.7.0 + ts-jest: + specifier: ^29.1.1 + version: 29.1.5(jest@29.7.0)(typescript@5.6.3) + typescript: + specifier: ^5.4.5 + version: 5.6.3 + libs/coin-modules/coin-near: dependencies: '@ledgerhq/coin-framework': @@ -63790,7 +63848,7 @@ snapshots: chalk: 4.1.2 enhanced-resolve: 5.17.1 micromatch: 4.0.7 - semver: 7.5.4 + semver: 7.6.3 source-map: 0.7.4 typescript: 5.1.3 webpack: 5.94.0