diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 4bc80e57983..a201bad020e 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -86,6 +86,7 @@ "@cardano-sdk/core": "workspace:~", "@cardano-sdk/crypto": "workspace:~", "@cardano-sdk/hardware-ledger": "workspace:~", + "@cardano-sdk/hardware-trezor": "workspace:~", "@cardano-sdk/input-selection": "workspace:~", "@cardano-sdk/key-management": "workspace:~", "@cardano-sdk/ogmios": "workspace:~", diff --git a/packages/e2e/src/factories.ts b/packages/e2e/src/factories.ts index 01115f82b95..e38904051a6 100644 --- a/packages/e2e/src/factories.ts +++ b/packages/e2e/src/factories.ts @@ -30,7 +30,6 @@ import { CommunicationType, InMemoryKeyAgent, KeyAgentDependencies, - TrezorKeyAgent, util } from '@cardano-sdk/key-management'; import { @@ -47,6 +46,7 @@ import { import { LedgerKeyAgent } from '@cardano-sdk/hardware-ledger'; import { Logger } from 'ts-log'; import { OgmiosTxSubmitProvider } from '@cardano-sdk/ogmios'; +import { TrezorKeyAgent } from '@cardano-sdk/hardware-trezor'; import { createConnectionObject } from '@cardano-ogmios/client'; import { createStubStakePoolProvider } from '@cardano-sdk/util-dev'; import { filter, firstValueFrom } from 'rxjs'; diff --git a/packages/e2e/src/tsconfig.json b/packages/e2e/src/tsconfig.json index e3be7ae06eb..3aa7451026c 100644 --- a/packages/e2e/src/tsconfig.json +++ b/packages/e2e/src/tsconfig.json @@ -33,6 +33,9 @@ }, { "path": "../../hardware-ledger/src" + }, + { + "path": "../../hardware-trezor/src" } ] } diff --git a/packages/hardware-ledger/src/transformers/certificates.ts b/packages/hardware-ledger/src/transformers/certificates.ts index 55817cbc18b..7204cfe6249 100644 --- a/packages/hardware-ledger/src/transformers/certificates.ts +++ b/packages/hardware-ledger/src/transformers/certificates.ts @@ -2,7 +2,7 @@ import * as Ledger from '@cardano-foundation/ledgerjs-hw-app-cardano'; import { Cardano } from '@cardano-sdk/core'; import { InvalidArgumentError, Transform } from '@cardano-sdk/util'; import { LedgerTxTransformerContext } from '../types'; -import { stakeKeyPathFromGroupedAddress } from './keyPaths'; +import { util } from '@cardano-sdk/key-management'; type StakeKeyCertificateType = Ledger.CertificateType.STAKE_REGISTRATION | Ledger.CertificateType.STAKE_DEREGISTRATION; @@ -23,7 +23,7 @@ const getStakeAddressCertificate = ( ); const rewardAddress = knownAddress ? Cardano.Address.fromBech32(knownAddress.rewardAccount)?.asReward() : null; - const path = stakeKeyPathFromGroupedAddress(knownAddress); + const path = util.stakeKeyPathFromGroupedAddress(knownAddress); const credentialType = rewardAddress ? rewardAddress.getPaymentCredential().type : Ledger.StakeCredentialParamsType.SCRIPT_HASH; @@ -75,7 +75,7 @@ export const stakeDelegationCertificate: Transform< ? rewardAddress.getPaymentCredential().type : Ledger.StakeCredentialParamsType.SCRIPT_HASH; - const path = stakeKeyPathFromGroupedAddress(knownAddress); + const path = util.stakeKeyPathFromGroupedAddress(knownAddress); let credential: Ledger.StakeCredentialParams; @@ -120,7 +120,7 @@ const getPoolOperatorKeyPath = ( context: LedgerTxTransformerContext ): Ledger.BIP32Path | null => { const knownAddress = context?.knownAddresses.find((address) => address.rewardAccount === operator); - return stakeKeyPathFromGroupedAddress(knownAddress); + return util.stakeKeyPathFromGroupedAddress(knownAddress); }; export const poolRegistrationCertificate: Transform< @@ -227,7 +227,7 @@ const poolRetirementCertificate: Transform< (address) => Cardano.RewardAccount.toHash(address.rewardAccount) === poolIdKeyHash ); - const poolKeyPath = stakeKeyPathFromGroupedAddress(knownAddress); + const poolKeyPath = util.stakeKeyPathFromGroupedAddress(knownAddress); if (!poolKeyPath) throw new InvalidArgumentError('certificate', 'Missing key matching pool retirement certificate.'); diff --git a/packages/hardware-ledger/src/transformers/index.ts b/packages/hardware-ledger/src/transformers/index.ts index f568fcace99..701ffa23c56 100644 --- a/packages/hardware-ledger/src/transformers/index.ts +++ b/packages/hardware-ledger/src/transformers/index.ts @@ -3,7 +3,6 @@ export * from './auxiliaryData'; export * from './certificates'; export * from './collateralOutput'; export * from './collateralInputs'; -export * from './keyPaths'; export * from './referenceInputs'; export * from './requiredSigners'; export * from './tx'; diff --git a/packages/hardware-ledger/src/transformers/keyPaths.ts b/packages/hardware-ledger/src/transformers/keyPaths.ts deleted file mode 100644 index bb3d30ba4cb..00000000000 --- a/packages/hardware-ledger/src/transformers/keyPaths.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as Ledger from '@cardano-foundation/ledgerjs-hw-app-cardano'; -import { CardanoKeyConst, GroupedAddress, util } from '@cardano-sdk/key-management'; - -export const paymentKeyPathFromGroupedAddress = (address: GroupedAddress): Ledger.BIP32Path => [ - util.harden(CardanoKeyConst.PURPOSE), - util.harden(CardanoKeyConst.COIN_TYPE), - util.harden(address.accountIndex), - address.type, - address.index -]; - -export const stakeKeyPathFromGroupedAddress = (address: GroupedAddress | undefined): Ledger.BIP32Path | null => { - if (!address) return null; - if (address && address.stakeKeyDerivationPath) { - return [ - util.harden(CardanoKeyConst.PURPOSE), - util.harden(CardanoKeyConst.COIN_TYPE), - util.harden(address.accountIndex), - address.stakeKeyDerivationPath.role, - address.stakeKeyDerivationPath.index - ]; - } - - return null; -}; diff --git a/packages/hardware-ledger/src/transformers/requiredSigners.ts b/packages/hardware-ledger/src/transformers/requiredSigners.ts index 4378bc87308..b672b821f75 100644 --- a/packages/hardware-ledger/src/transformers/requiredSigners.ts +++ b/packages/hardware-ledger/src/transformers/requiredSigners.ts @@ -3,7 +3,7 @@ import * as Ledger from '@cardano-foundation/ledgerjs-hw-app-cardano'; import { Cardano } from '@cardano-sdk/core'; import { LedgerTxTransformerContext } from '../types'; import { Transform } from '@cardano-sdk/util'; -import { paymentKeyPathFromGroupedAddress, stakeKeyPathFromGroupedAddress } from './keyPaths'; +import { util } from '@cardano-sdk/key-management'; export const toRequiredSigner: Transform< Crypto.Ed25519KeyHashHex, @@ -20,8 +20,10 @@ export const toRequiredSigner: Transform< return stakeCredential && stakeCredential.toString() === keyHash; }); - const paymentKeyPath = paymentCredKnownAddress ? paymentKeyPathFromGroupedAddress(paymentCredKnownAddress) : null; - const stakeKeyPath = stakeCredKnownAddress ? stakeKeyPathFromGroupedAddress(stakeCredKnownAddress) : null; + const paymentKeyPath = paymentCredKnownAddress + ? util.paymentKeyPathFromGroupedAddress(paymentCredKnownAddress) + : null; + const stakeKeyPath = stakeCredKnownAddress ? util.stakeKeyPathFromGroupedAddress(stakeCredKnownAddress) : null; if (paymentKeyPath) { return { diff --git a/packages/hardware-ledger/src/transformers/txIn.ts b/packages/hardware-ledger/src/transformers/txIn.ts index 06c0780f13b..d536febcc00 100644 --- a/packages/hardware-ledger/src/transformers/txIn.ts +++ b/packages/hardware-ledger/src/transformers/txIn.ts @@ -2,7 +2,7 @@ import * as Ledger from '@cardano-foundation/ledgerjs-hw-app-cardano'; import { Cardano } from '@cardano-sdk/core'; import { LedgerTxTransformerContext } from '../types'; import { Transform } from '@cardano-sdk/util'; -import { paymentKeyPathFromGroupedAddress } from './keyPaths'; +import { util } from '@cardano-sdk/key-management'; const resolveKeyPath = async ( txIn: Cardano.TxIn, @@ -16,7 +16,7 @@ const resolveKeyPath = async ( const knownAddress = context.knownAddresses.find(({ address }) => address === txOut.address); if (knownAddress) { - paymentKeyPath = paymentKeyPathFromGroupedAddress(knownAddress); + paymentKeyPath = util.paymentKeyPathFromGroupedAddress(knownAddress); } } diff --git a/packages/hardware-ledger/src/transformers/txOut.ts b/packages/hardware-ledger/src/transformers/txOut.ts index 36607a71a62..a99e53f07cf 100644 --- a/packages/hardware-ledger/src/transformers/txOut.ts +++ b/packages/hardware-ledger/src/transformers/txOut.ts @@ -3,7 +3,7 @@ import { Cardano, Serialization } from '@cardano-sdk/core'; import { HexBlob, InvalidArgumentError, Transform } from '@cardano-sdk/util'; import { LedgerTxTransformerContext } from '../types'; import { mapTokenMap } from './assets'; -import { paymentKeyPathFromGroupedAddress, stakeKeyPathFromGroupedAddress } from './keyPaths'; +import { util } from '@cardano-sdk/key-management'; const toInlineDatum: Transform = (datum) => ({ datumHex: Serialization.PlutusData.fromCore(datum).toCbor(), @@ -22,8 +22,8 @@ const toDestination: Transform address.address === txOut.address); if (knownAddress) { - const paymentKeyPath = paymentKeyPathFromGroupedAddress(knownAddress); - const stakeKeyPath = stakeKeyPathFromGroupedAddress(knownAddress); + const paymentKeyPath = util.paymentKeyPathFromGroupedAddress(knownAddress); + const stakeKeyPath = util.stakeKeyPathFromGroupedAddress(knownAddress); if (!stakeKeyPath) throw new InvalidArgumentError('txOut', 'Missing stake key key path.'); diff --git a/packages/hardware-trezor/.gitignore b/packages/hardware-trezor/.gitignore new file mode 100644 index 00000000000..5a19e8ace41 --- /dev/null +++ b/packages/hardware-trezor/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +coverage \ No newline at end of file diff --git a/packages/hardware-trezor/LICENSE b/packages/hardware-trezor/LICENSE new file mode 100644 index 00000000000..74eb3172a7d --- /dev/null +++ b/packages/hardware-trezor/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright © 2022 IOHK + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/hardware-trezor/NOTICE b/packages/hardware-trezor/NOTICE new file mode 100644 index 00000000000..c5fcf9cc247 --- /dev/null +++ b/packages/hardware-trezor/NOTICE @@ -0,0 +1,5 @@ +Copyright 2023 IOHK + +Licensed under the Apache License, Version 2.0 (the "License”). You may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.txt + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/packages/hardware-trezor/README.md b/packages/hardware-trezor/README.md new file mode 100644 index 00000000000..d77f3aa0748 --- /dev/null +++ b/packages/hardware-trezor/README.md @@ -0,0 +1 @@ +# Cardano JS SDK | Hardware | Trezor diff --git a/packages/hardware-trezor/package.json b/packages/hardware-trezor/package.json new file mode 100644 index 00000000000..8dd5ccc82b0 --- /dev/null +++ b/packages/hardware-trezor/package.json @@ -0,0 +1,68 @@ +{ + "name": "@cardano-sdk/hardware-trezor", + "version": "0.1.0", + "description": "Mappings and integration with Trezor hardware", + "engines": { + "node": ">=16.20.1" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + } + }, + "repository": "https://github.com/input-output-hk/cardano-js-sdk", + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "license": "Apache-2.0", + "scripts": { + "build:esm": "tsc -p src/tsconfig.json --outDir ./dist/esm --module es2020", + "build:cjs": "tsc --build src", + "build": "run-s build:cjs build:esm module-fixup", + "circular-deps:check": "madge --circular dist/cjs", + "module-fixup": "shx cp ../../build/cjs-package.json ./dist/cjs/package.json && cp ../../build/esm-package.json ./dist/esm/package.json", + "tscNoEmit": "shx echo typescript --noEmit command not implemented yet", + "cleanup:dist": "shx rm -rf dist", + "cleanup:nm": "shx rm -rf node_modules", + "cleanup": "run-s cleanup:dist cleanup:nm", + "lint": "eslint -c ../../complete.eslintrc.js \"src/**/*.ts\" \"test/**/*.ts\"", + "lint:fix": "yarn lint --fix", + "test": "jest -c test/jest.config.js", + "test:build:verify": "tsc --build ./test", + "test:e2e": "shx echo 'test:e2e' command not implemented yet", + "coverage": "yarn test --coverage", + "prepack": "yarn build" + }, + "devDependencies": { + "eslint": "^7.32.0", + "jest": "^28.1.3", + "madge": "^5.0.1", + "npm-run-all": "^4.1.5", + "shx": "^0.3.3", + "ts-jest": "^28.0.7", + "ts-log": "2.2.4", + "typescript": "^4.7.4" + }, + "dependencies": { + "@cardano-sdk/core": "workspace:~", + "@cardano-sdk/crypto": "workspace:~", + "@cardano-sdk/key-management": "workspace:~", + "@cardano-sdk/tx-construction": "workspace:~", + "@cardano-sdk/util": "workspace:~", + "@trezor/connect": "9.0.11", + "@trezor/connect-web": "9.0.11", + "lodash": "^4.17.21", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4" + }, + "files": [ + "dist/*", + "!dist/tsconfig.tsbuildinfo", + "LICENSE", + "NOTICE" + ] +} diff --git a/packages/hardware-trezor/src/TrezorKeyAgent.ts b/packages/hardware-trezor/src/TrezorKeyAgent.ts new file mode 100644 index 00000000000..de98cb4c8f7 --- /dev/null +++ b/packages/hardware-trezor/src/TrezorKeyAgent.ts @@ -0,0 +1,212 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as Crypto from '@cardano-sdk/crypto'; +import * as Trezor from '@trezor/connect'; +import { Cardano, NotImplementedError } from '@cardano-sdk/core'; +import { + CardanoKeyConst, + CommunicationType, + KeyAgentBase, + KeyAgentDependencies, + KeyAgentType, + SerializableTrezorKeyAgentData, + SignBlobResult, + TrezorConfig, + errors +} from '@cardano-sdk/key-management'; +import { txToTrezor } from './transformers/tx'; +import TrezorConnectWeb from '@trezor/connect-web'; + +const TrezorConnectNode = Trezor.default; + +const transportTypedError = (error?: any) => + new errors.AuthenticationError( + 'Trezor transport failed', + new errors.TransportError('Trezor transport failed', error) + ); + +export interface TrezorKeyAgentProps extends Omit { + isTrezorInitialized?: boolean; +} + +export interface GetTrezorXpubProps { + accountIndex: number; + communicationType: CommunicationType; +} + +export interface CreateTrezorKeyAgentProps { + chainId: Cardano.ChainId; + accountIndex?: number; + trezorConfig: TrezorConfig; +} + +export type TrezorConnectInstanceType = typeof TrezorConnectNode | typeof TrezorConnectWeb; + +const getTrezorConnect = (communicationType: CommunicationType): TrezorConnectInstanceType => + communicationType === CommunicationType.Node ? TrezorConnectNode : TrezorConnectWeb; + +export class TrezorKeyAgent extends KeyAgentBase { + readonly isTrezorInitialized: Promise; + readonly #communicationType: CommunicationType; + + constructor({ isTrezorInitialized, ...serializableData }: TrezorKeyAgentProps, dependencies: KeyAgentDependencies) { + super({ ...serializableData, __typename: KeyAgentType.Trezor }, dependencies); + if (!isTrezorInitialized) { + this.isTrezorInitialized = TrezorKeyAgent.initializeTrezorTransport(serializableData.trezorConfig); + } + this.#communicationType = serializableData.trezorConfig.communicationType; + } + + static async initializeTrezorTransport({ + manifest, + communicationType, + silentMode = false, + lazyLoad = false + }: TrezorConfig): Promise { + const trezorConnect = getTrezorConnect(communicationType); + try { + await trezorConnect.init({ + // eslint-disable-next-line max-len + // Set to "false" (default) if you want to start communication with bridge on application start (and detect connected device right away) + // Set it to "true", then trezor-connect will not be initialized until you call some trezorConnect.method() + // This is useful when you don't know if you are dealing with Trezor user + lazyLoad: communicationType !== CommunicationType.Node && lazyLoad, + // Manifest is required from Trezor Connect 7: + // https://github.com/trezor/connect/blob/develop/docs/index.md#trezor-connect-manifest + manifest, + // Show Trezor Suite popup. Disabled for node based apps + popup: communicationType !== CommunicationType.Node && !silentMode + }); + return true; + } catch (error: any) { + if (error.code === 'Init_AlreadyInitialized') return true; + throw transportTypedError(error); + } + } + + static async checkDeviceConnection(communicationType: CommunicationType): Promise { + const trezorConnect = getTrezorConnect(communicationType); + try { + const deviceFeatures = await trezorConnect.getFeatures(); + if (!deviceFeatures.success) { + throw new errors.TransportError('Failed to get device', deviceFeatures.payload); + } + if (deviceFeatures.payload.model !== 'T') { + throw new errors.TransportError(`Trezor device model "${deviceFeatures.payload.model}" is not supported.`); + } + return deviceFeatures.payload; + } catch (error) { + throw transportTypedError(error); + } + } + + static async getXpub({ accountIndex, communicationType }: GetTrezorXpubProps): Promise { + try { + await TrezorKeyAgent.checkDeviceConnection(communicationType); + const derivationPath = `m/${CardanoKeyConst.PURPOSE}'/${CardanoKeyConst.COIN_TYPE}'/${accountIndex}'`; + const trezorConnect = getTrezorConnect(communicationType); + const extendedPublicKey = await trezorConnect.cardanoGetPublicKey({ + path: derivationPath, + showOnTrezor: true + }); + if (!extendedPublicKey.success) { + throw new errors.TransportError('Failed to export extended account public key', extendedPublicKey.payload); + } + return Crypto.Bip32PublicKeyHex(extendedPublicKey.payload.publicKey); + } catch (error: any) { + throw transportTypedError(error); + } + } + + static async createWithDevice( + { chainId, accountIndex = 0, trezorConfig }: CreateTrezorKeyAgentProps, + dependencies: KeyAgentDependencies + ) { + const isTrezorInitialized = await TrezorKeyAgent.initializeTrezorTransport(trezorConfig); + const extendedAccountPublicKey = await TrezorKeyAgent.getXpub({ + accountIndex, + communicationType: trezorConfig.communicationType + }); + return new TrezorKeyAgent( + { + accountIndex, + chainId, + extendedAccountPublicKey, + isTrezorInitialized, + knownAddresses: [], + trezorConfig + }, + dependencies + ); + } + + /** + * Gets the mode in which we want to sign the transaction. + */ + static getSigningMode(tx: Omit): Trezor.PROTO.CardanoTxSigningMode { + if (tx.certificates) { + for (const cert of tx.certificates) { + // Represents pool registration from the perspective of a pool owner. + if ( + cert.type === Trezor.PROTO.CardanoCertificateType.STAKE_POOL_REGISTRATION && + cert.poolParameters?.owners.some((owner) => owner.stakingKeyPath) + ) + return Trezor.PROTO.CardanoTxSigningMode.POOL_REGISTRATION_AS_OWNER; + } + } + + // Represents an ordinary user transaction transferring funds. + return Trezor.PROTO.CardanoTxSigningMode.ORDINARY_TRANSACTION; + } + + async signTransaction(tx: Cardano.TxBodyWithHash): Promise { + try { + await this.isTrezorInitialized; + const trezorTxData = await txToTrezor({ + cardanoTxBody: tx.body, + chainId: this.chainId, + inputResolver: this.inputResolver, + knownAddresses: this.knownAddresses + }); + + const signingMode = TrezorKeyAgent.getSigningMode(trezorTxData); + + const trezorConnect = getTrezorConnect(this.#communicationType); + const result = await trezorConnect.cardanoSignTransaction({ + ...trezorTxData, + signingMode + }); + if (!result.success) { + throw new errors.TransportError('Failed to export extended account public key', result.payload); + } + + const signedData = result.payload; + + if (signedData.hash !== tx.hash) { + throw new errors.HwMappingError('Trezor computed a different transaction id'); + } + + return new Map( + await Promise.all( + signedData.witnesses.map(async (witness) => { + const publicKey = Crypto.Ed25519PublicKeyHex(witness.pubKey); + const signature = Crypto.Ed25519SignatureHex(witness.signature); + return [publicKey, signature] as const; + }) + ) + ); + } catch (error: any) { + if (error.innerError.code === 'Failure_ActionCancelled') { + throw new errors.AuthenticationError('Transaction signing aborted', error); + } + throw transportTypedError(error); + } + } + + async signBlob(): Promise { + throw new NotImplementedError('signBlob'); + } + + async exportRootPrivateKey(): Promise { + throw new NotImplementedError('Operation not supported!'); + } +} diff --git a/packages/hardware-trezor/src/index.ts b/packages/hardware-trezor/src/index.ts new file mode 100644 index 00000000000..7e0d7b3d821 --- /dev/null +++ b/packages/hardware-trezor/src/index.ts @@ -0,0 +1,3 @@ +export * from './transformers'; +export * from './types'; +export * from './TrezorKeyAgent'; diff --git a/packages/hardware-trezor/src/transformers/additionalWitnessRequests.ts b/packages/hardware-trezor/src/transformers/additionalWitnessRequests.ts new file mode 100644 index 00000000000..425219528a2 --- /dev/null +++ b/packages/hardware-trezor/src/transformers/additionalWitnessRequests.ts @@ -0,0 +1,22 @@ +import * as Trezor from '@trezor/connect'; +import { BIP32Path } from '@cardano-sdk/crypto'; +import { TrezorTxTransformerContext } from '../types'; +import { isNotNil } from '@cardano-sdk/util'; +import { util } from '@cardano-sdk/key-management'; +import isArray from 'lodash/isArray'; +import uniq from 'lodash/uniq'; + +export const mapAdditionalWitnessRequests = (inputs: Trezor.CardanoInput[], context: TrezorTxTransformerContext) => { + const paymentKeyPaths = uniq( + inputs + .map((i) => i.path) + .filter(isNotNil) + .filter(isArray) + ); + const additionalWitnessPaths: BIP32Path[] = [...paymentKeyPaths]; + if (context.knownAddresses.length > 0) { + const stakeKeyPath = util.stakeKeyPathFromGroupedAddress(context.knownAddresses[0]); + if (stakeKeyPath) additionalWitnessPaths.push(stakeKeyPath); + } + return additionalWitnessPaths; +}; diff --git a/packages/hardware-trezor/src/transformers/assets.ts b/packages/hardware-trezor/src/transformers/assets.ts new file mode 100644 index 00000000000..77476deb8ca --- /dev/null +++ b/packages/hardware-trezor/src/transformers/assets.ts @@ -0,0 +1,45 @@ +import * as Trezor from '@trezor/connect'; +import { Cardano } from '@cardano-sdk/core'; + +const compareAssetNameCanonically = (a: Trezor.CardanoToken, b: Trezor.CardanoToken) => { + if (a.assetNameBytes.length === b.assetNameBytes.length) { + return a.assetNameBytes > b.assetNameBytes ? 1 : -1; + } else if (a.assetNameBytes.length > b.assetNameBytes.length) return 1; + return -1; +}; + +const comparePolicyIdCanonically = (a: Trezor.CardanoAssetGroup, b: Trezor.CardanoAssetGroup) => + // PolicyId is always of the same length + a.policyId > b.policyId ? 1 : -1; + +const tokenMapToAssetGroup = (tokenMap: Cardano.TokenMap): Trezor.CardanoAssetGroup[] => { + const map = new Map>(); + + for (const [key, value] of tokenMap.entries()) { + const policyId = Cardano.AssetId.getPolicyId(key); + const assetName = Cardano.AssetId.getAssetName(key); + + if (!map.has(policyId)) map.set(policyId, []); + + map.get(policyId)!.push({ + amount: value.toString(), + assetNameBytes: assetName + }); + } + + const tokenMapAssetsGroup = []; + for (const [key, value] of map.entries()) { + value.sort(compareAssetNameCanonically); + tokenMapAssetsGroup.push({ + policyId: key, + tokenAmounts: value + }); + } + + tokenMapAssetsGroup.sort(comparePolicyIdCanonically); + + return tokenMapAssetsGroup; +}; + +export const mapTokenMap = (tokenMap: Cardano.TokenMap | undefined) => + tokenMap ? tokenMapToAssetGroup(tokenMap) : undefined; diff --git a/packages/hardware-trezor/src/transformers/auxiliaryData.ts b/packages/hardware-trezor/src/transformers/auxiliaryData.ts new file mode 100644 index 00000000000..4dad81f6fca --- /dev/null +++ b/packages/hardware-trezor/src/transformers/auxiliaryData.ts @@ -0,0 +1,6 @@ +import * as Crypto from '@cardano-sdk/crypto'; +import * as Trezor from '@trezor/connect'; + +export const mapAuxiliaryData = (auxiliaryDataHash: Crypto.Hash32ByteBase16): Trezor.CardanoAuxiliaryData => ({ + hash: auxiliaryDataHash +}); diff --git a/packages/hardware-trezor/src/transformers/certificates.ts b/packages/hardware-trezor/src/transformers/certificates.ts new file mode 100644 index 00000000000..4d80f5ce5a1 --- /dev/null +++ b/packages/hardware-trezor/src/transformers/certificates.ts @@ -0,0 +1,172 @@ +import * as Crypto from '@cardano-sdk/crypto'; +import * as Trezor from '@trezor/connect'; +import { BIP32Path } from '@cardano-sdk/crypto'; +import { Cardano } from '@cardano-sdk/core'; +import { GroupedAddress, util } from '@cardano-sdk/key-management'; +import { InvalidArgumentError /* , Transform*/ } from '@cardano-sdk/util'; +import { TrezorTxTransformerContext } from '../types'; + +type StakeKeyCertificateType = + | Trezor.PROTO.CardanoCertificateType.STAKE_REGISTRATION + | Trezor.PROTO.CardanoCertificateType.STAKE_DEREGISTRATION; + +type TrezorStakeKeyCertificate = { + type: StakeKeyCertificateType; + path?: BIP32Path; + scriptHash?: Crypto.Ed25519KeyHashHex; + keyHash?: Crypto.Ed25519KeyHashHex; +}; + +type TrezorDelegationCertificate = { + type: Trezor.PROTO.CardanoCertificateType.STAKE_DELEGATION; + path?: BIP32Path; + scriptHash?: Crypto.Ed25519KeyHashHex; + pool: string; +}; + +type TrezorPoolRegistrationCertificate = { + poolParameters: Trezor.CardanoPoolParameters; + type: Trezor.PROTO.CardanoCertificateType.STAKE_POOL_REGISTRATION; +}; + +type ScriptHashCertCredentials = { + scriptHash: Crypto.Ed25519KeyHashHex; +}; + +type KeyHashCertCredentials = { + keyHash: Crypto.Ed25519KeyHashHex; +}; + +type PathCertCredentials = { + path: BIP32Path; +}; + +type CertCredentialsType = ScriptHashCertCredentials | KeyHashCertCredentials | PathCertCredentials; + +const getCertCredentials = ( + stakeKeyHash: Crypto.Ed25519KeyHashHex, + knownAddresses: GroupedAddress[] | undefined +): CertCredentialsType => { + const knownAddress = knownAddresses?.find( + (address) => Cardano.RewardAccount.toHash(address.rewardAccount) === stakeKeyHash + ); + const rewardAddress = knownAddress ? Cardano.Address.fromBech32(knownAddress.rewardAccount)?.asReward() : null; + + if (rewardAddress?.getPaymentCredential().type === Cardano.CredentialType.KeyHash) { + const path = util.stakeKeyPathFromGroupedAddress(knownAddress); + return path ? { path } : { keyHash: stakeKeyHash }; + } + return { + scriptHash: stakeKeyHash + }; +}; + +const getStakeAddressCertificate = ( + certificate: Cardano.StakeAddressCertificate, + context: TrezorTxTransformerContext, + type: StakeKeyCertificateType +): TrezorStakeKeyCertificate => { + const credentials = getCertCredentials(certificate.stakeKeyHash, context.knownAddresses); + return { + ...credentials, + type + }; +}; + +const getStakeDelegationCertificate = ( + certificate: Cardano.StakeDelegationCertificate, + context: TrezorTxTransformerContext +): TrezorDelegationCertificate => { + const poolIdKeyHash = Cardano.PoolId.toKeyHash(certificate.poolId); + const credentials = getCertCredentials(certificate.stakeKeyHash, context.knownAddresses); + return { + ...credentials, + pool: poolIdKeyHash, + type: Trezor.PROTO.CardanoCertificateType.STAKE_DELEGATION + }; +}; + +const toPoolMetadata = (metadataJson: Cardano.PoolMetadataJson): Trezor.CardanoPoolMetadata => ({ + hash: metadataJson.hash, + url: metadataJson.url +}); + +const getPoolOperatorKeyPath = ( + operator: Cardano.RewardAccount, + context: TrezorTxTransformerContext +): BIP32Path | null => { + const knownAddress = context?.knownAddresses.find((address) => address.rewardAccount === operator); + return util.stakeKeyPathFromGroupedAddress(knownAddress); +}; + +export const getPoolRegistrationCertificate = ( + certificate: Cardano.PoolRegistrationCertificate, + context: TrezorTxTransformerContext +): TrezorPoolRegistrationCertificate => { + if (!certificate.poolParameters.metadataJson) + throw new InvalidArgumentError('certificate', 'Missing pool registration pool metadata.'); + return { + poolParameters: { + cost: certificate.poolParameters.cost.toString(), + margin: { + denominator: certificate.poolParameters.margin.denominator.toString(), + numerator: certificate.poolParameters.margin.numerator.toString() + }, + metadata: toPoolMetadata(certificate.poolParameters.metadataJson), + owners: certificate.poolParameters.owners.map((owner) => { + const poolOwnerKeyPath = getPoolOperatorKeyPath(owner, context!); + const poolOwnerKeyHash = Cardano.RewardAccount.toHash(owner); + return poolOwnerKeyPath ? { stakingKeyPath: poolOwnerKeyPath } : { stakingKeyHash: poolOwnerKeyHash }; + }), + pledge: certificate.poolParameters.pledge.toString(), + poolId: Cardano.PoolId.toKeyHash(certificate.poolParameters.id), + relays: certificate.poolParameters.relays.map((relay) => { + switch (relay.__typename) { + case 'RelayByAddress': + return { + ipv4Address: relay.ipv4, + ipv6Address: relay.ipv6, + port: relay.port, + type: Trezor.PROTO.CardanoPoolRelayType.SINGLE_HOST_IP + }; + case 'RelayByName': + return { + hostName: relay.hostname, + port: relay.port, + type: Trezor.PROTO.CardanoPoolRelayType.SINGLE_HOST_NAME + }; + case 'RelayByNameMultihost': + return { + hostName: relay.dnsName, + type: Trezor.PROTO.CardanoPoolRelayType.MULTIPLE_HOST_NAME + }; + default: + throw new InvalidArgumentError('certificate', 'Unknown relay type.'); + } + }), + rewardAccount: certificate.poolParameters.rewardAccount, + vrfKeyHash: certificate.poolParameters.vrf + }, + type: Trezor.PROTO.CardanoCertificateType.STAKE_POOL_REGISTRATION + }; +}; + +const toCert = (cert: Cardano.Certificate, context: TrezorTxTransformerContext) => { + switch (cert.__typename) { + case Cardano.CertificateType.StakeKeyRegistration: + return getStakeAddressCertificate(cert, context, Trezor.PROTO.CardanoCertificateType.STAKE_REGISTRATION); + case Cardano.CertificateType.StakeKeyDeregistration: + return getStakeAddressCertificate(cert, context, Trezor.PROTO.CardanoCertificateType.STAKE_DEREGISTRATION); + case Cardano.CertificateType.StakeDelegation: + return getStakeDelegationCertificate(cert, context); + case Cardano.CertificateType.PoolRegistration: + return getPoolRegistrationCertificate(cert, context); + default: + throw new InvalidArgumentError('cert', `Certificate ${cert.__typename} not supported.`); + } +}; + +export const mapCerts = ( + certs: Cardano.Certificate[], + context: TrezorTxTransformerContext +): Trezor.CardanoCertificate[] => certs.map((coreCert) => toCert(coreCert, context)); diff --git a/packages/hardware-trezor/src/transformers/index.ts b/packages/hardware-trezor/src/transformers/index.ts new file mode 100644 index 00000000000..a3af3125133 --- /dev/null +++ b/packages/hardware-trezor/src/transformers/index.ts @@ -0,0 +1,6 @@ +export * from './keyPaths'; +export * from './txIn'; +export * from './txOut'; +export * from './withdrawals'; +export * from './certificates'; +export * from './auxiliaryData'; diff --git a/packages/hardware-trezor/src/transformers/keyPaths.ts b/packages/hardware-trezor/src/transformers/keyPaths.ts new file mode 100644 index 00000000000..1154f00cc7c --- /dev/null +++ b/packages/hardware-trezor/src/transformers/keyPaths.ts @@ -0,0 +1,30 @@ +import { BIP32Path } from '@cardano-sdk/crypto'; +import { Cardano } from '@cardano-sdk/core'; +import { TrezorTxTransformerContext } from '../types'; +import { util } from '@cardano-sdk/key-management'; + +/** + * Uses the given Trezor input resolver to resolve the payment key + * path for known addresses for given input transaction. + */ +export const resolvePaymentKeyPathForTxIn = async ( + txIn: Cardano.TxIn, + context?: TrezorTxTransformerContext +): Promise => { + if (!context) return; + const txOut = await context.inputResolver.resolveInput(txIn); + const knownAddress = context.knownAddresses.find(({ address }) => address === txOut?.address); + return knownAddress ? util.paymentKeyPathFromGroupedAddress(knownAddress) : undefined; +}; + +// Resolves the stake key path for known addresses for the given reward address. +export const resolveStakeKeyPath = ( + rewardAddress: Cardano.RewardAddress | undefined, + context: TrezorTxTransformerContext +): BIP32Path | null => { + if (!rewardAddress) return null; + const knownAddress = context.knownAddresses.find( + ({ rewardAccount }) => rewardAccount === rewardAddress.toAddress().toBech32() + ); + return util.stakeKeyPathFromGroupedAddress(knownAddress); +}; diff --git a/packages/hardware-trezor/src/transformers/tx.ts b/packages/hardware-trezor/src/transformers/tx.ts new file mode 100644 index 00000000000..1f9fbaa0e5f --- /dev/null +++ b/packages/hardware-trezor/src/transformers/tx.ts @@ -0,0 +1,56 @@ +import * as Trezor from '@trezor/connect'; +import { Cardano } from '@cardano-sdk/core'; +import { GroupedAddress } from '@cardano-sdk/key-management'; +import { TrezorTxTransformerContext } from '../types'; +import { mapAdditionalWitnessRequests } from './additionalWitnessRequests'; +import { mapAuxiliaryData, mapCerts, mapTxIns, mapTxOuts, mapWithdrawals } from './'; +import { mapTokenMap } from './assets'; + +/** + * Temporary transformer function that returns a partial + * trezor.CardanoSignTransaction object which can be merged + * into the extisting implementation (later we should refactor + * this function to the Transformer interface like in the + * hardware-ledger package) + */ +const trezorTxTransformer = async ( + body: Cardano.TxBody, + context: TrezorTxTransformerContext +): Promise> => { + const inputs = await mapTxIns(body.inputs, context); + return { + additionalWitnessRequests: mapAdditionalWitnessRequests(inputs, context), + auxiliaryData: body.auxiliaryDataHash ? mapAuxiliaryData(body.auxiliaryDataHash) : undefined, + certificates: mapCerts(body.certificates ?? [], context), + fee: body.fee.toString(), + inputs, + mint: mapTokenMap(body.mint), + networkId: context.chainId.networkId, + outputs: mapTxOuts(body.outputs, context), + protocolMagic: context.chainId.networkMagic, + ttl: body.validityInterval?.invalidHereafter?.toString(), + validityIntervalStart: body.validityInterval?.invalidBefore?.toString(), + withdrawals: mapWithdrawals(body.withdrawals ?? [], context) + }; +}; + +/** + * Takes a core transaction and context data necessary to transform + * it into a trezor.CardanoSignTransaction + */ +export const txToTrezor = ({ + cardanoTxBody, + chainId, + inputResolver, + knownAddresses +}: { + chainId: Cardano.ChainId; + inputResolver: Cardano.InputResolver; + knownAddresses: GroupedAddress[]; + cardanoTxBody: Cardano.TxBody; +}): Promise> => + trezorTxTransformer(cardanoTxBody, { + chainId, + inputResolver, + knownAddresses + }); diff --git a/packages/hardware-trezor/src/transformers/txIn.ts b/packages/hardware-trezor/src/transformers/txIn.ts new file mode 100644 index 00000000000..04d7a83b260 --- /dev/null +++ b/packages/hardware-trezor/src/transformers/txIn.ts @@ -0,0 +1,31 @@ +import * as Trezor from '@trezor/connect'; +import { Cardano } from '@cardano-sdk/core'; +import { Transform } from '@cardano-sdk/util'; +import { TrezorTxTransformerContext } from '../types'; +import { resolvePaymentKeyPathForTxIn } from './keyPaths'; + +/** + * Transforms the given Cardano input transaction to the Trezor + * input transaction format using the given trezor input resolver. + */ +export const toTrezorTxIn: Transform, TrezorTxTransformerContext> = async ( + txIn, + context? +) => { + const path = await resolvePaymentKeyPathForTxIn(txIn, context); + return { + prev_hash: txIn.txId, + prev_index: txIn.index, + ...(path ? { path } : {}) // optional destructuring of path + }; +}; + +/** + * Transforms an array of core Cardano transaction inputs to + * an array of trezor Cardano transaction inputs using the + * given context. + */ +export const mapTxIns = async ( + txIns: Cardano.TxIn[], + context: TrezorTxTransformerContext +): Promise => Promise.all(txIns.map((txIn) => toTrezorTxIn(txIn, context))); diff --git a/packages/hardware-trezor/src/transformers/txOut.ts b/packages/hardware-trezor/src/transformers/txOut.ts new file mode 100644 index 00000000000..5388813c125 --- /dev/null +++ b/packages/hardware-trezor/src/transformers/txOut.ts @@ -0,0 +1,49 @@ +import * as Trezor from '@trezor/connect'; +import { Cardano } from '@cardano-sdk/core'; +import { GroupedAddress, util } from '@cardano-sdk/key-management'; +import { InvalidArgumentError, Transform } from '@cardano-sdk/util'; +import { TrezorTxOutputDestination, TrezorTxTransformerContext } from '../types'; +import { mapTokenMap } from './assets'; + +const toDestination: Transform = ( + txOut, + context +) => { + const knownAddress = context?.knownAddresses.find((address: GroupedAddress) => address.address === txOut.address); + + if (!knownAddress) { + return { + address: txOut.address + }; + } + + const paymentPath = util.paymentKeyPathFromGroupedAddress(knownAddress); + const stakingPath = util.stakeKeyPathFromGroupedAddress(knownAddress); + + if (!stakingPath) throw new InvalidArgumentError('txOut', 'Missing staking key key path.'); + + return { + addressParameters: { + addressType: Trezor.PROTO.CardanoAddressType.BASE, + path: paymentPath, + stakingPath + } + }; +}; + +// TODO - use Transform (@cardano-sdk/util) once it is fixed. Even if prop is marked as optional it has to be added to fullfil Transform rules e.g. datumHash +export const toTxOut = (txOut: Cardano.TxOut, context: TrezorTxTransformerContext): Trezor.CardanoOutput => { + const destination = toDestination(txOut, context); + const trezorTxOut = { + ...destination, + amount: txOut.value.coins.toString() + }; + if (txOut.value.assets) { + const tokenBundle = mapTokenMap(txOut.value.assets); + Object.assign(trezorTxOut, { tokenBundle }); + } + return trezorTxOut; +}; + +export const mapTxOuts = (txOuts: Cardano.TxOut[], context: TrezorTxTransformerContext): Trezor.CardanoOutput[] => + txOuts.map((txOut) => toTxOut(txOut, context)); diff --git a/packages/hardware-trezor/src/transformers/withdrawals.ts b/packages/hardware-trezor/src/transformers/withdrawals.ts new file mode 100644 index 00000000000..c8bc0cf693d --- /dev/null +++ b/packages/hardware-trezor/src/transformers/withdrawals.ts @@ -0,0 +1,48 @@ +import * as Trezor from '@trezor/connect'; +import { Cardano } from '@cardano-sdk/core'; +import { InvalidArgumentError } from '@cardano-sdk/util'; +import { TrezorTxTransformerContext } from '../types'; +import { resolveStakeKeyPath } from './keyPaths'; + +export const toTrezorWithdrawal = ( + withdrawal: Cardano.Withdrawal, + context: TrezorTxTransformerContext +): Trezor.CardanoWithdrawal => { + const address = Cardano.Address.fromString(withdrawal.stakeAddress); + const rewardAddress = address?.asReward(); + + if (!rewardAddress) throw new InvalidArgumentError('withdrawal', 'Invalid withdrawal stake address'); + + let trezorWithdrawal; + /** + * The credential specifies who should control the funds or the staking rights for that address. + * + * The credential type could be: + * - The stake key hash or payment key hash, blake2b-224 hash digests of Ed25519 verification keys. + * - The script hash, blake2b-224 hash digests of serialized monetary scripts. + */ + if (rewardAddress.getPaymentCredential().type === Cardano.CredentialType.KeyHash) { + const keyPath = resolveStakeKeyPath(rewardAddress, context); + trezorWithdrawal = keyPath + ? { + amount: withdrawal.quantity.toString(), + path: keyPath + } + : { + amount: withdrawal.quantity.toString(), + keyHash: rewardAddress.getPaymentCredential().hash.toString() + }; + } else { + trezorWithdrawal = { + amount: withdrawal.quantity.toString(), + scriptHash: rewardAddress.getPaymentCredential().hash.toString() + }; + } + + return trezorWithdrawal; +}; + +export const mapWithdrawals = ( + withdrawals: Cardano.Withdrawal[], + context: TrezorTxTransformerContext +): Trezor.CardanoWithdrawal[] => withdrawals.map((coreWithdrawal) => toTrezorWithdrawal(coreWithdrawal, context)); diff --git a/packages/hardware-trezor/src/tsconfig.json b/packages/hardware-trezor/src/tsconfig.json new file mode 100644 index 00000000000..63f1f51bff6 --- /dev/null +++ b/packages/hardware-trezor/src/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../dist/cjs", + }, + "references": [ + { + "path": "../../core/src" + }, + { + "path": "../../key-management/src" + }, + { + "path": "../../util/src" + } + ] +} diff --git a/packages/hardware-trezor/src/types.ts b/packages/hardware-trezor/src/types.ts new file mode 100644 index 00000000000..8c7e1c74479 --- /dev/null +++ b/packages/hardware-trezor/src/types.ts @@ -0,0 +1,25 @@ +import * as Trezor from '@trezor/connect'; +import { Cardano } from '@cardano-sdk/core'; +import { GroupedAddress } from '@cardano-sdk/key-management'; + +/** + * The TrezorTxTransformerContext type represents the additional context necessary for + * transforming a core transaction into a Trezor device compatible transaction. + * + * @property {Cardano.ChainId} chainId - The Cardano blockchain's network identifier (e.g., mainnet or testnet). + * @property {Cardano.InputResolver} inputResolver - A function that resolves transaction txOut from the given txIn. + * @property {GroupedAddress[]} knownAddresses - An array of grouped known addresses by wallet. + */ +export type TrezorTxTransformerContext = { + chainId: Cardano.ChainId; + inputResolver: Cardano.InputResolver; + knownAddresses: GroupedAddress[]; +}; + +export type TrezorTxOutputDestination = + | { + addressParameters: Trezor.CardanoAddressParameters; + } + | { + address: string; + }; diff --git a/packages/hardware-trezor/test/jest.config.js b/packages/hardware-trezor/test/jest.config.js new file mode 100644 index 00000000000..5d3d994e8b2 --- /dev/null +++ b/packages/hardware-trezor/test/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../../test/jest.config'); diff --git a/packages/hardware-trezor/test/testData.ts b/packages/hardware-trezor/test/testData.ts new file mode 100644 index 00000000000..628932674d5 --- /dev/null +++ b/packages/hardware-trezor/test/testData.ts @@ -0,0 +1,197 @@ +import * as Crypto from '@cardano-sdk/crypto'; +import { AddressType, GroupedAddress, KeyRole } from '@cardano-sdk/key-management'; +import { Cardano } from '@cardano-sdk/core'; + +export const mintTokenMap = new Map([ + [Cardano.AssetId('2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740'), 20n], + [Cardano.AssetId('659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba8254534c41'), -50n], + [Cardano.AssetId('7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373'), 40n], + [Cardano.AssetId('7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373504154415445'), 30n] +]); + +export const valueWithAssets = { + assets: new Map([ + [Cardano.AssetId('2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740'), 20n], + [Cardano.AssetId('659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba8254534c41'), 50n], + [Cardano.AssetId('7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373'), 40n], + [Cardano.AssetId('7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373504154415445'), 30n] + ]), + coins: 10n +}; + +export const txIn: Cardano.TxIn = { + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') +}; + +export const paymentAddress = Cardano.PaymentAddress( + 'addr1qxdtr6wjx3kr7jlrvrfzhrh8w44qx9krcxhvu3e79zr7497tpmpxjfyhk3vwg6qjezjmlg5nr5dzm9j6nxyns28vsy8stu5lh6' +); + +export const txOut: Cardano.TxOut = { + address: Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ), + value: { + coins: 10n + } +}; + +export const txOutWithAssets: Cardano.TxOut = { + address: Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ), + value: valueWithAssets +}; + +export const txOutWithAssetsToOwnedAddress: Cardano.TxOut = { + address: paymentAddress, + value: valueWithAssets +}; + +export const txOutToOwnedAddress: Cardano.TxOut = { + address: paymentAddress, + value: { + coins: 10n + } +}; + +export const rewardKey = 'stake1u89sasnfyjtmgk8ydqfv3fdl52f36x3djedfnzfc9rkgzrcss5vgr'; +export const rewardScript = 'stake178phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcccycj5'; +export const rewardAccount = Cardano.RewardAccount(rewardKey); +export const rewardAddress = Cardano.Address.fromBech32(rewardAccount)?.asReward(); +export const rewardAccountWithPaymentScriptCredential = Cardano.RewardAccount(rewardScript); +export const stakeKeyHash = Cardano.RewardAccount.toHash(rewardAccount); +export const stakeScriptHash = Cardano.RewardAccount.toHash(rewardAccountWithPaymentScriptCredential); +export const knownAddressKeyPath = [2_147_485_500, 2_147_485_463, 2_147_483_648, 1, 0]; +export const knownAddressStakeKeyPath = [2_147_485_500, 2_147_485_463, 2_147_483_648, 2, 0]; +export const poolId = Cardano.PoolId('pool1ev8vy6fyj7693ergzty2t0azjvw35tvkt2vcjwpgajqs7z6u2vn'); +export const poolId2 = Cardano.PoolId('pool1z5uqdk7dzdxaae5633fqfcu2eqzy3a3rgtuvy087fdld7yws0xt'); +export const vrf = Cardano.VrfVkHex('8dd154228946bd12967c12bedb1cb6038b78f8b84a1760b1a788fa72a4af3db0'); +export const metadataJson = { + hash: Crypto.Hash32ByteBase16('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'), + url: 'https://example.com' +}; +export const auxiliaryDataHash = Crypto.Hash32ByteBase16( + '2ceb364d93225b4a0f004a0975a13eb50c3cc6348474b4fe9121f8dc72ca0cfa' +); + +const stakeKeyDerivationPath = { + index: 0, + role: KeyRole.Stake +}; + +export const knownAddress: GroupedAddress = { + accountIndex: 0, + address: paymentAddress, + index: 0, + networkId: Cardano.NetworkId.Testnet, + rewardAccount, + stakeKeyDerivationPath, + type: AddressType.Internal +}; + +export const knownAddressWithoutStakingPath: GroupedAddress = { + accountIndex: 0, + address: paymentAddress, + index: 0, + networkId: Cardano.NetworkId.Testnet, + rewardAccount, + type: AddressType.Internal +}; + +export const contextWithKnownAddresses = { + chainId: { + networkId: Cardano.NetworkId.Testnet, + networkMagic: 999 + }, + inputResolver: { resolveInput: () => Promise.resolve(txOutToOwnedAddress) }, + knownAddresses: [knownAddress] +}; + +export const contextWithKnownAddressesWithoutStakingCredentials = { + chainId: { + networkId: Cardano.NetworkId.Testnet, + networkMagic: 999 + }, + inputResolver: { resolveInput: () => Promise.resolve(txOutToOwnedAddress) }, + knownAddresses: [knownAddressWithoutStakingPath] +}; + +export const contextWithoutKnownAddresses = { + chainId: { + networkId: Cardano.NetworkId.Testnet, + networkMagic: 999 + }, + inputResolver: { resolveInput: () => Promise.resolve(null) }, + knownAddresses: [] +}; + +export const coreWithdrawalWithKeyHashCredential = { + quantity: 5n, + stakeAddress: rewardAccount +}; + +export const coreWithdrawalWithScriptHashCredential = { + quantity: 5n, + stakeAddress: rewardAccountWithPaymentScriptCredential +}; + +export const stakeRegistrationCertificate = { + __typename: Cardano.CertificateType.StakeKeyRegistration, + stakeKeyHash +} as Cardano.StakeAddressCertificate; + +export const stakeDeregistrationCertificate = { + __typename: Cardano.CertificateType.StakeKeyDeregistration, + stakeKeyHash +} as Cardano.StakeAddressCertificate; + +export const stakeDelegationCertificate = { + __typename: Cardano.CertificateType.StakeDelegation, + poolId: poolId2, + stakeKeyHash +} as Cardano.StakeDelegationCertificate; + +export const poolRegistrationCertificate = { + __typename: Cardano.CertificateType.PoolRegistration, + poolParameters: { + cost: 1000n, + id: poolId, + margin: { denominator: 5, numerator: 1 }, + metadataJson, + owners: [rewardAccount], + pledge: 10_000n, + relays: [ + { + __typename: 'RelayByAddress', + ipv4: '127.0.0.1', + port: 6000 + }, + { __typename: 'RelayByName', hostname: 'example.com', port: 5000 }, + { __typename: 'RelayByNameMultihost', dnsName: 'example.com' } + ], + rewardAccount, + vrf + } +} as Cardano.PoolRegistrationCertificate; + +export const minValidTxBody: Cardano.TxBody = { + fee: 10n, + inputs: [txIn], + outputs: [txOut] +}; + +export const txBody: Cardano.TxBody = { + auxiliaryDataHash, + certificates: [stakeDelegationCertificate], + fee: 10n, + inputs: [txIn], + mint: mintTokenMap, + outputs: [txOutWithAssets, txOutWithAssetsToOwnedAddress], + validityInterval: { + invalidBefore: Cardano.Slot(100), + invalidHereafter: Cardano.Slot(1000) + }, + withdrawals: [coreWithdrawalWithKeyHashCredential] +}; diff --git a/packages/hardware-trezor/test/transformers/additionalWitnessRequests.test.ts b/packages/hardware-trezor/test/transformers/additionalWitnessRequests.test.ts new file mode 100644 index 00000000000..c2483dde5e6 --- /dev/null +++ b/packages/hardware-trezor/test/transformers/additionalWitnessRequests.test.ts @@ -0,0 +1,14 @@ +import { contextWithKnownAddresses, txIn } from '../testData'; +import { mapAdditionalWitnessRequests } from '../../src/transformers/additionalWitnessRequests'; +import { toTrezorTxIn } from '../../src'; + +describe('additionalWitnessRequests', () => { + it('should include payment key paths and reward account key path from given inputs', async () => { + const mappedTrezorTxIn = await toTrezorTxIn(txIn, contextWithKnownAddresses); + const result = mapAdditionalWitnessRequests([mappedTrezorTxIn], contextWithKnownAddresses); + expect(result).toEqual([ + [2_147_485_500, 2_147_485_463, 2_147_483_648, 1, 0], // payment key path + [2_147_485_500, 2_147_485_463, 2_147_483_648, 2, 0] // reward account key path + ]); + }); +}); diff --git a/packages/hardware-trezor/test/transformers/assets.test.ts b/packages/hardware-trezor/test/transformers/assets.test.ts new file mode 100644 index 00000000000..71a46a8fe8d --- /dev/null +++ b/packages/hardware-trezor/test/transformers/assets.test.ts @@ -0,0 +1,34 @@ +import { Cardano } from '@cardano-sdk/core'; +import { mapTokenMap } from '../../src/transformers/assets'; +import { mintTokenMap } from '../testData'; + +describe('assets', () => { + describe('mapTokenMap', () => { + it('returns undefined when given an undefined token map', async () => { + const tokeMap: Cardano.TokenMap | undefined = undefined; + const trezorAssets = mapTokenMap(tokeMap); + expect(trezorAssets).toBeUndefined(); + }); + + it('can map a valid token map to asset group', async () => { + const trezorAssets = mapTokenMap(mintTokenMap); + expect(trezorAssets).toEqual([ + { + policyId: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', + tokenAmounts: [{ amount: '20', assetNameBytes: '' }] + }, + { + policyId: '659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba82', + tokenAmounts: [{ amount: '-50', assetNameBytes: '54534c41' }] + }, + { + policyId: '7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373', + tokenAmounts: [ + { amount: '40', assetNameBytes: '' }, + { amount: '30', assetNameBytes: '504154415445' } + ] + } + ]); + }); + }); +}); diff --git a/packages/hardware-trezor/test/transformers/auxiliaryData.test.ts b/packages/hardware-trezor/test/transformers/auxiliaryData.test.ts new file mode 100644 index 00000000000..0c19ba85295 --- /dev/null +++ b/packages/hardware-trezor/test/transformers/auxiliaryData.test.ts @@ -0,0 +1,14 @@ +import { auxiliaryDataHash } from '../testData'; +import { mapAuxiliaryData } from '../../src/transformers/auxiliaryData'; + +describe('auxiliaryData', () => { + describe('mapAuxiliaryData', () => { + it('can map a valid auxiliary data hash', async () => { + const hash = mapAuxiliaryData(auxiliaryDataHash); + + expect(hash).toEqual({ + hash: '2ceb364d93225b4a0f004a0975a13eb50c3cc6348474b4fe9121f8dc72ca0cfa' + }); + }); + }); +}); diff --git a/packages/hardware-trezor/test/transformers/certificates.test.ts b/packages/hardware-trezor/test/transformers/certificates.test.ts new file mode 100644 index 00000000000..5c3a2169999 --- /dev/null +++ b/packages/hardware-trezor/test/transformers/certificates.test.ts @@ -0,0 +1,241 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import * as Trezor from '@trezor/connect'; +import { Cardano } from '@cardano-sdk/core'; +import { CardanoKeyConst, KeyRole, util } from '@cardano-sdk/key-management'; +import { + contextWithKnownAddresses, + contextWithKnownAddressesWithoutStakingCredentials, + contextWithoutKnownAddresses, + poolRegistrationCertificate, + stakeDelegationCertificate, + stakeDeregistrationCertificate, + stakeRegistrationCertificate +} from '../testData'; +import { mapCerts } from '../../src/transformers'; + +describe('certificates', () => { + describe('mapCerts', () => { + it('returns an empty array if there are no certificates', async () => { + const certs: Cardano.Certificate[] = []; + const trezorCerts = mapCerts(certs, contextWithKnownAddresses); + + expect(trezorCerts).toEqual([]); + }); + + describe('stake registration and deregistration certificates', () => { + it('can map a stake key stake registration certificate', async () => { + const certificates = mapCerts([stakeRegistrationCertificate], contextWithKnownAddresses); + + expect(certificates).toEqual([ + { + path: [ + util.harden(CardanoKeyConst.PURPOSE), + util.harden(CardanoKeyConst.COIN_TYPE), + util.harden(0), + KeyRole.Stake, + 0 + ], + type: Trezor.PROTO.CardanoCertificateType.STAKE_REGISTRATION + } + ]); + }); + + it('can map a stake key stake deregistration certificate', async () => { + const certificates = mapCerts([stakeDeregistrationCertificate], contextWithKnownAddresses); + + expect(certificates).toEqual([ + { + path: [ + util.harden(CardanoKeyConst.PURPOSE), + util.harden(CardanoKeyConst.COIN_TYPE), + util.harden(0), + KeyRole.Stake, + 0 + ], + type: Trezor.PROTO.CardanoCertificateType.STAKE_DEREGISTRATION + } + ]); + }); + + it('can map a key hash stake registration certificate', async () => { + const certificates = mapCerts( + [stakeRegistrationCertificate], + contextWithKnownAddressesWithoutStakingCredentials + ); + + expect(certificates).toEqual([ + { + keyHash: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f', + type: Trezor.PROTO.CardanoCertificateType.STAKE_REGISTRATION + } + ]); + }); + + it('can map a key hash stake deregistration certificate', async () => { + const certificates = mapCerts( + [stakeDeregistrationCertificate], + contextWithKnownAddressesWithoutStakingCredentials + ); + + expect(certificates).toEqual([ + { + keyHash: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f', + type: Trezor.PROTO.CardanoCertificateType.STAKE_DEREGISTRATION + } + ]); + }); + + it('can map a script hash stake registration certificate', async () => { + const certificates = mapCerts([stakeRegistrationCertificate], contextWithoutKnownAddresses); + + expect(certificates).toEqual([ + { + scriptHash: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f', + type: Trezor.PROTO.CardanoCertificateType.STAKE_REGISTRATION + } + ]); + }); + + it('can map a script hash stake deregistration certificate', async () => { + const certificates = mapCerts([stakeDeregistrationCertificate], contextWithoutKnownAddresses); + + expect(certificates).toEqual([ + { + scriptHash: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f', + type: Trezor.PROTO.CardanoCertificateType.STAKE_DEREGISTRATION + } + ]); + }); + }); + + describe('stake delegation certificates', () => { + it('can map a delegation certificate with known stake key', async () => { + const certificates = mapCerts([stakeDelegationCertificate], contextWithKnownAddresses); + + expect(certificates).toEqual([ + { + path: [util.harden(CardanoKeyConst.PURPOSE), util.harden(CardanoKeyConst.COIN_TYPE), util.harden(0), 2, 0], + pool: '153806dbcd134ddee69a8c5204e38ac80448f62342f8c23cfe4b7edf', + type: Trezor.PROTO.CardanoCertificateType.STAKE_DELEGATION + } + ]); + }); + + it('can map a delegation certificate with unknown stake key', async () => { + const certificates = mapCerts([stakeDelegationCertificate], contextWithoutKnownAddresses); + + expect(certificates).toEqual([ + { + pool: '153806dbcd134ddee69a8c5204e38ac80448f62342f8c23cfe4b7edf', + scriptHash: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f', + type: Trezor.PROTO.CardanoCertificateType.STAKE_DELEGATION + } + ]); + }); + + it('can map a delegation certificate with known address and unknown stake key', async () => { + const certificates = mapCerts([stakeDelegationCertificate], contextWithKnownAddressesWithoutStakingCredentials); + + expect(certificates).toEqual([ + { + keyHash: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f', + pool: '153806dbcd134ddee69a8c5204e38ac80448f62342f8c23cfe4b7edf', + type: Trezor.PROTO.CardanoCertificateType.STAKE_DELEGATION + } + ]); + }); + }); + + describe('pool registration certificates', () => { + it('can map a pool registration certificate with known keys', async () => { + expect(mapCerts([poolRegistrationCertificate], contextWithKnownAddresses)).toEqual([ + { + poolParameters: { + cost: '1000', + margin: { + denominator: '5', + numerator: '1' + }, + metadata: { + hash: '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5', + url: 'https://example.com' + }, + owners: [ + { + stakingKeyPath: [ + util.harden(CardanoKeyConst.PURPOSE), + util.harden(CardanoKeyConst.COIN_TYPE), + util.harden(0), + 2, + 0 + ] + } + ], + pledge: '10000', + poolId: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f', + relays: [ + { + ipv4Address: '127.0.0.1', + port: 6000, + type: 0 + }, + { + hostName: 'example.com', + port: 5000, + type: 1 + }, + { + hostName: 'example.com', + type: 2 + } + ], + rewardAccount: 'stake1u89sasnfyjtmgk8ydqfv3fdl52f36x3djedfnzfc9rkgzrcss5vgr', + vrfKeyHash: '8dd154228946bd12967c12bedb1cb6038b78f8b84a1760b1a788fa72a4af3db0' + }, + type: Trezor.PROTO.CardanoCertificateType.STAKE_POOL_REGISTRATION + } + ]); + }); + + it('can map a pool registration certificate with unknown keys', async () => { + expect(mapCerts([poolRegistrationCertificate], contextWithoutKnownAddresses)).toEqual([ + { + poolParameters: { + cost: '1000', + margin: { + denominator: '5', + numerator: '1' + }, + metadata: { + hash: '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5', + url: 'https://example.com' + }, + owners: [{ stakingKeyHash: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f' }], + pledge: '10000', + poolId: 'cb0ec2692497b458e46812c8a5bfa2931d1a2d965a99893828ec810f', + relays: [ + { + ipv4Address: '127.0.0.1', + port: 6000, + type: 0 + }, + { + hostName: 'example.com', + port: 5000, + type: 1 + }, + { + hostName: 'example.com', + type: 2 + } + ], + rewardAccount: 'stake1u89sasnfyjtmgk8ydqfv3fdl52f36x3djedfnzfc9rkgzrcss5vgr', + vrfKeyHash: '8dd154228946bd12967c12bedb1cb6038b78f8b84a1760b1a788fa72a4af3db0' + }, + type: Trezor.PROTO.CardanoCertificateType.STAKE_POOL_REGISTRATION + } + ]); + }); + }); + }); +}); diff --git a/packages/hardware-trezor/test/transformers/keyPaths.test.ts b/packages/hardware-trezor/test/transformers/keyPaths.test.ts new file mode 100644 index 00000000000..b21a23edd2a --- /dev/null +++ b/packages/hardware-trezor/test/transformers/keyPaths.test.ts @@ -0,0 +1,21 @@ +import { + contextWithKnownAddresses, + knownAddressKeyPath, + knownAddressStakeKeyPath, + rewardAddress, + txIn +} from '../testData'; +import { resolvePaymentKeyPathForTxIn, resolveStakeKeyPath } from '../../src'; + +describe('key-paths', () => { + describe('resolvePaymentKeyPathForTxIn', () => { + it('returns the payment key path for a known address', async () => { + expect(await resolvePaymentKeyPathForTxIn(txIn, contextWithKnownAddresses)).toEqual(knownAddressKeyPath); + }); + }); + describe('resolveStakeKeyPath', () => { + it('returns the stake key path for a known address', async () => { + expect(resolveStakeKeyPath(rewardAddress, contextWithKnownAddresses)).toEqual(knownAddressStakeKeyPath); + }); + }); +}); diff --git a/packages/hardware-trezor/test/transformers/tx.test.ts b/packages/hardware-trezor/test/transformers/tx.test.ts new file mode 100644 index 00000000000..3aa5b22d01b --- /dev/null +++ b/packages/hardware-trezor/test/transformers/tx.test.ts @@ -0,0 +1,189 @@ +import * as Trezor from '@trezor/connect'; +import { CardanoKeyConst, util } from '@cardano-sdk/key-management'; +import { + contextWithKnownAddresses, + contextWithoutKnownAddresses, + knownAddressKeyPath, + knownAddressStakeKeyPath, + minValidTxBody, + txBody, + txIn +} from '../testData'; +import { txToTrezor } from '../../src/transformers/tx'; + +describe('tx', () => { + describe('txToTrezor', () => { + test('can map min valid transaction', async () => { + expect( + await txToTrezor({ + ...contextWithoutKnownAddresses, + cardanoTxBody: minValidTxBody + }) + ).toEqual({ + additionalWitnessRequests: [], + auxiliaryData: undefined, + certificates: [], + fee: '10', + inputs: [ + { + prev_hash: txIn.txId, + prev_index: txIn.index + } + ], + mint: undefined, + networkId: 0, + outputs: [ + { + address: + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp', + amount: '10' + } + ], + protocolMagic: 999, + ttl: undefined, + validityIntervalStart: undefined, + withdrawals: [] + }); + }); + + test('can map transaction', async () => { + expect( + await txToTrezor({ + ...contextWithKnownAddresses, + cardanoTxBody: txBody + }) + ).toEqual({ + additionalWitnessRequests: [ + [2_147_485_500, 2_147_485_463, 2_147_483_648, 1, 0], // payment key path + [2_147_485_500, 2_147_485_463, 2_147_483_648, 2, 0] // reward account key path + ], + auxiliaryData: { + hash: '2ceb364d93225b4a0f004a0975a13eb50c3cc6348474b4fe9121f8dc72ca0cfa' + }, + certificates: [ + { + path: [util.harden(CardanoKeyConst.PURPOSE), util.harden(CardanoKeyConst.COIN_TYPE), util.harden(0), 2, 0], + pool: '153806dbcd134ddee69a8c5204e38ac80448f62342f8c23cfe4b7edf', + type: Trezor.PROTO.CardanoCertificateType.STAKE_DELEGATION + } + ], + fee: '10', + inputs: [ + { + path: knownAddressKeyPath, + prev_hash: txIn.txId, + prev_index: txIn.index + } + ], + mint: [ + { + policyId: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', + tokenAmounts: [{ amount: '20', assetNameBytes: '' }] + }, + { + policyId: '659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba82', + tokenAmounts: [{ amount: '-50', assetNameBytes: '54534c41' }] + }, + { + policyId: '7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373', + tokenAmounts: [ + { amount: '40', assetNameBytes: '' }, + { amount: '30', assetNameBytes: '504154415445' } + ] + } + ], + networkId: 0, + outputs: [ + { + address: + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp', + amount: '10', + tokenBundle: [ + { + policyId: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', + tokenAmounts: [ + { + amount: '20', + assetNameBytes: '' + } + ] + }, + { + policyId: '659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba82', + tokenAmounts: [ + { + amount: '50', + assetNameBytes: '54534c41' + } + ] + }, + { + policyId: '7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373', + tokenAmounts: [ + { + amount: '40', + assetNameBytes: '' + }, + { + amount: '30', + assetNameBytes: '504154415445' + } + ] + } + ] + }, + { + addressParameters: { + addressType: Trezor.PROTO.CardanoAddressType.BASE, + path: knownAddressKeyPath, + stakingPath: knownAddressStakeKeyPath + }, + amount: '10', + tokenBundle: [ + { + policyId: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', + tokenAmounts: [ + { + amount: '20', + assetNameBytes: '' + } + ] + }, + { + policyId: '659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba82', + tokenAmounts: [ + { + amount: '50', + assetNameBytes: '54534c41' + } + ] + }, + { + policyId: '7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373', + tokenAmounts: [ + { + amount: '40', + assetNameBytes: '' + }, + { + amount: '30', + assetNameBytes: '504154415445' + } + ] + } + ] + } + ], + protocolMagic: 999, + ttl: '1000', + validityIntervalStart: '100', + withdrawals: [ + { + amount: '5', + path: [util.harden(CardanoKeyConst.PURPOSE), util.harden(CardanoKeyConst.COIN_TYPE), util.harden(0), 2, 0] + } + ] + }); + }); + }); +}); diff --git a/packages/hardware-trezor/test/transformers/txIn.test.ts b/packages/hardware-trezor/test/transformers/txIn.test.ts new file mode 100644 index 00000000000..70d3e3c06ca --- /dev/null +++ b/packages/hardware-trezor/test/transformers/txIn.test.ts @@ -0,0 +1,36 @@ +import { contextWithKnownAddresses, contextWithoutKnownAddresses, knownAddressKeyPath, txIn } from '../testData'; +import { mapTxIns, toTrezorTxIn } from '../../src'; + +const expectedTrezorTxInWithKnownAddress = { + path: knownAddressKeyPath, + prev_hash: txIn.txId, + prev_index: txIn.index +}; + +const expectedTrezorTxInWithoutKnownAddress = { + prev_hash: txIn.txId, + prev_index: txIn.index +}; + +describe('tx-inputs', () => { + describe('toTrezorTxIn', () => { + it('maps a simple tx input from an unknown third party address', async () => { + const mappedTrezorTxIn = await toTrezorTxIn(txIn, contextWithoutKnownAddresses); + expect(mappedTrezorTxIn).toEqual(expectedTrezorTxInWithoutKnownAddress); + }); + it('maps a simple tx input from a known address', async () => { + const mappedTrezorTxIn = await toTrezorTxIn(txIn, contextWithKnownAddresses); + expect(mappedTrezorTxIn).toEqual(expectedTrezorTxInWithKnownAddress); + }); + }); + describe('mapTxIns', () => { + it('can map a a set of TxIns', async () => { + const txIns = await mapTxIns([txIn, txIn, txIn], contextWithKnownAddresses); + expect(txIns).toEqual([ + expectedTrezorTxInWithKnownAddress, + expectedTrezorTxInWithKnownAddress, + expectedTrezorTxInWithKnownAddress + ]); + }); + }); +}); diff --git a/packages/hardware-trezor/test/transformers/txOut.test.ts b/packages/hardware-trezor/test/transformers/txOut.test.ts new file mode 100644 index 00000000000..f65ed6a9f1a --- /dev/null +++ b/packages/hardware-trezor/test/transformers/txOut.test.ts @@ -0,0 +1,261 @@ +import * as Trezor from '@trezor/connect'; +import { + contextWithKnownAddresses, + knownAddressKeyPath, + knownAddressStakeKeyPath, + txOut, + txOutToOwnedAddress, + txOutWithAssets, + txOutWithAssetsToOwnedAddress +} from '../testData'; +import { mapTxOuts, toTxOut } from '../../src/transformers/txOut'; + +describe('txOut', () => { + describe('mapTxOuts', () => { + it('can map a set of transaction outputs to third party address', async () => { + const txOuts = mapTxOuts([txOut, txOut, txOut], contextWithKnownAddresses); + + expect(txOuts.length).toEqual(3); + + for (const out of txOuts) { + expect(out).toEqual({ + address: + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp', + amount: '10' + }); + } + }); + + it('can map a set of transaction outputs with assets to third party address', async () => { + const txOuts = mapTxOuts([txOutWithAssets, txOutWithAssets, txOutWithAssets], contextWithKnownAddresses); + + expect(txOuts.length).toEqual(3); + + for (const out of txOuts) { + expect(out).toEqual({ + address: + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp', + amount: '10', + tokenBundle: [ + { + policyId: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', + tokenAmounts: [ + { + amount: '20', + assetNameBytes: '' + } + ] + }, + { + policyId: '659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba82', + tokenAmounts: [ + { + amount: '50', + assetNameBytes: '54534c41' + } + ] + }, + { + policyId: '7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373', + tokenAmounts: [ + { + amount: '40', + assetNameBytes: '' + }, + { + amount: '30', + assetNameBytes: '504154415445' + } + ] + } + ] + }); + } + }); + + it('can map a set of transaction outputs to owned address', async () => { + const txOuts = mapTxOuts( + [txOutToOwnedAddress, txOutToOwnedAddress, txOutToOwnedAddress], + contextWithKnownAddresses + ); + + expect(txOuts.length).toEqual(3); + + for (const out of txOuts) { + expect(out).toEqual({ + addressParameters: { + addressType: Trezor.PROTO.CardanoAddressType.BASE, + path: knownAddressKeyPath, + stakingPath: knownAddressStakeKeyPath + }, + amount: '10' + }); + } + }); + + it('can map a set of transaction outputs with assets to owned address', async () => { + const txOuts = mapTxOuts( + [txOutWithAssetsToOwnedAddress, txOutWithAssetsToOwnedAddress, txOutWithAssetsToOwnedAddress], + contextWithKnownAddresses + ); + + expect(txOuts.length).toEqual(3); + + for (const out of txOuts) { + expect(out).toEqual({ + addressParameters: { + addressType: Trezor.PROTO.CardanoAddressType.BASE, + path: knownAddressKeyPath, + stakingPath: knownAddressStakeKeyPath + }, + amount: '10', + tokenBundle: [ + { + policyId: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', + tokenAmounts: [ + { + amount: '20', + assetNameBytes: '' + } + ] + }, + { + policyId: '659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba82', + tokenAmounts: [ + { + amount: '50', + assetNameBytes: '54534c41' + } + ] + }, + { + policyId: '7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373', + tokenAmounts: [ + { + amount: '40', + assetNameBytes: '' + }, + { + amount: '30', + assetNameBytes: '504154415445' + } + ] + } + ] + }); + } + }); + }); + + describe('toTxOut', () => { + it('can map a simple transaction output to third party address', async () => { + const out = toTxOut(txOut, contextWithKnownAddresses); + expect(out).toEqual({ + address: + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp', + amount: '10' + }); + }); + + it('can map a simple transaction output with assets to third party address', async () => { + const out = toTxOut(txOutWithAssets, contextWithKnownAddresses); + expect(out).toEqual({ + address: + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp', + amount: '10', + tokenBundle: [ + { + policyId: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', + tokenAmounts: [ + { + amount: '20', + assetNameBytes: '' + } + ] + }, + { + policyId: '659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba82', + tokenAmounts: [ + { + amount: '50', + assetNameBytes: '54534c41' + } + ] + }, + { + policyId: '7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373', + tokenAmounts: [ + { + amount: '40', + assetNameBytes: '' + }, + { + amount: '30', + assetNameBytes: '504154415445' + } + ] + } + ] + }); + }); + + it('can map a simple transaction output to owned address', async () => { + const out = toTxOut(txOutToOwnedAddress, contextWithKnownAddresses); + + expect(out).toEqual({ + addressParameters: { + addressType: Trezor.PROTO.CardanoAddressType.BASE, + path: knownAddressKeyPath, + stakingPath: knownAddressStakeKeyPath + }, + amount: '10' + }); + }); + + it('can map a simple transaction output with assets to owned address', async () => { + const out = toTxOut(txOutWithAssetsToOwnedAddress, contextWithKnownAddresses); + + expect(out).toEqual({ + addressParameters: { + addressType: Trezor.PROTO.CardanoAddressType.BASE, + path: knownAddressKeyPath, + stakingPath: knownAddressStakeKeyPath + }, + amount: '10', + tokenBundle: [ + { + policyId: '2a286ad895d091f2b3d168a6091ad2627d30a72761a5bc36eef00740', + tokenAmounts: [ + { + amount: '20', + assetNameBytes: '' + } + ] + }, + { + policyId: '659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba82', + tokenAmounts: [ + { + amount: '50', + assetNameBytes: '54534c41' + } + ] + }, + { + policyId: '7eae28af2208be856f7a119668ae52a49b73725e326dc16579dcc373', + tokenAmounts: [ + { + amount: '40', + assetNameBytes: '' + }, + { + amount: '30', + assetNameBytes: '504154415445' + } + ] + } + ] + }); + }); + }); +}); diff --git a/packages/hardware-trezor/test/transformers/withdrawals.test.ts b/packages/hardware-trezor/test/transformers/withdrawals.test.ts new file mode 100644 index 00000000000..c238a6e5b3c --- /dev/null +++ b/packages/hardware-trezor/test/transformers/withdrawals.test.ts @@ -0,0 +1,60 @@ +import { Cardano } from '@cardano-sdk/core'; +import { CardanoKeyConst, util } from '@cardano-sdk/key-management'; +import { + contextWithKnownAddresses, + contextWithoutKnownAddresses, + coreWithdrawalWithKeyHashCredential, + coreWithdrawalWithScriptHashCredential, + stakeKeyHash, + stakeScriptHash +} from '../testData'; +import { mapWithdrawals, toTrezorWithdrawal } from '../../src/transformers'; + +describe('withdrawals', () => { + describe('mapWithdrawals', () => { + it('returns an empty array if there are no withdrawals', async () => { + const withdrawals: Cardano.Withdrawal[] = []; + const txIns = mapWithdrawals(withdrawals, contextWithKnownAddresses); + expect(txIns).toEqual([]); + }); + + it('can map a a set of withdrawals', async () => { + const withdrawals = await mapWithdrawals( + [coreWithdrawalWithKeyHashCredential, coreWithdrawalWithKeyHashCredential, coreWithdrawalWithKeyHashCredential], + contextWithKnownAddresses + ); + + const expectedWithdrawal = { + amount: '5', + path: [util.harden(CardanoKeyConst.PURPOSE), util.harden(CardanoKeyConst.COIN_TYPE), util.harden(0), 2, 0] + }; + expect(withdrawals).toEqual([expectedWithdrawal, expectedWithdrawal, expectedWithdrawal]); + }); + }); + + describe('toTrezorWithdrawals', () => { + it('can map a withdrawal with known address', async () => { + const withdrawal = toTrezorWithdrawal(coreWithdrawalWithKeyHashCredential, contextWithKnownAddresses); + expect(withdrawal).toEqual({ + amount: '5', + path: [util.harden(CardanoKeyConst.PURPOSE), util.harden(CardanoKeyConst.COIN_TYPE), util.harden(0), 2, 0] + }); + }); + + it('can map a withdrawal with unknown address', async () => { + const withdrawal = toTrezorWithdrawal(coreWithdrawalWithKeyHashCredential, contextWithoutKnownAddresses); + expect(withdrawal).toEqual({ + amount: '5', + keyHash: stakeKeyHash + }); + }); + + it('can map a withdrawal with script credential', async () => { + const withdrawal = toTrezorWithdrawal(coreWithdrawalWithScriptHashCredential, contextWithoutKnownAddresses); + expect(withdrawal).toEqual({ + amount: '5', + scriptHash: stakeScriptHash + }); + }); + }); +}); diff --git a/packages/hardware-trezor/test/tsconfig.json b/packages/hardware-trezor/test/tsconfig.json new file mode 100644 index 00000000000..671480eaf55 --- /dev/null +++ b/packages/hardware-trezor/test/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../test/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "module": "commonjs", + "noEmitOnError": false + }, + "references": [ + { + "path": "../../core/src" + }, + { + "path": "../../key-management/src" + }, + { + "path": "../../util/src" + } + ] +} diff --git a/packages/key-management/src/TrezorKeyAgent.ts b/packages/key-management/src/TrezorKeyAgent.ts deleted file mode 100644 index 4adb8a538e3..00000000000 --- a/packages/key-management/src/TrezorKeyAgent.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import * as Crypto from '@cardano-sdk/crypto'; -import { Cardano, NotImplementedError } from '@cardano-sdk/core'; -import { - CommunicationType, - KeyAgentDependencies, - SerializableTrezorKeyAgentData, - SignBlobResult, - TrezorConfig -} from './types'; -import { KeyAgentBase } from './KeyAgentBase'; -import TrezorConnectNode, { Features } from '@trezor/connect'; -import TrezorConnectWeb from '@trezor/connect-web'; - -export interface TrezorKeyAgentProps extends Omit { - isTrezorInitialized?: boolean; -} - -export interface GetTrezorXpubProps { - accountIndex: number; - communicationType: CommunicationType; -} - -export interface CreateTrezorKeyAgentProps { - chainId: Cardano.ChainId; - accountIndex?: number; - trezorConfig: TrezorConfig; -} - -export type TrezorConnectInstanceType = typeof TrezorConnectNode | typeof TrezorConnectWeb; - -export class TrezorKeyAgent extends KeyAgentBase { - static async initializeTrezorTransport(__config: TrezorConfig): Promise { - throw new NotImplementedError('initializeTrezorTransport'); - } - - static async checkDeviceConnection(_communicationType: CommunicationType): Promise { - throw new NotImplementedError('checkDeviceConnection'); - } - - static async getXpub(_props: GetTrezorXpubProps): Promise { - throw new NotImplementedError('getXpub'); - } - - static async createWithDevice( - _props: CreateTrezorKeyAgentProps, - _dependencies: KeyAgentDependencies - ): Promise { - throw new NotImplementedError('createWithDevice'); - } - - async signTransaction(_body: Cardano.TxBodyWithHash): Promise { - throw new NotImplementedError('signTransaction'); - } - - async signBlob(): Promise { - throw new NotImplementedError('signBlob'); - } - - async exportRootPrivateKey(): Promise { - throw new NotImplementedError('Operation not supported!'); - } -} diff --git a/packages/key-management/src/index.ts b/packages/key-management/src/index.ts index f8514ff2c46..e4f3883fb08 100644 --- a/packages/key-management/src/index.ts +++ b/packages/key-management/src/index.ts @@ -1,4 +1,3 @@ -import { TrezorKeyAgent } from './TrezorKeyAgent'; export * as errors from './errors'; export * from './KeyAgentBase'; export * from './InMemoryKeyAgent'; @@ -6,4 +5,3 @@ export * as util from './util'; export * from './emip3'; export * from './types'; export * as cip8 from './cip8'; -export { TrezorKeyAgent }; diff --git a/packages/key-management/src/util/key.ts b/packages/key-management/src/util/key.ts index efa9bae419d..af379a714ab 100644 --- a/packages/key-management/src/util/key.ts +++ b/packages/key-management/src/util/key.ts @@ -1,5 +1,6 @@ import * as Crypto from '@cardano-sdk/crypto'; -import { AccountKeyDerivationPath, CardanoKeyConst, Ed25519KeyPair, KeyPair, KeyRole } from '../types'; +import { AccountKeyDerivationPath, CardanoKeyConst, Ed25519KeyPair, GroupedAddress, KeyPair, KeyRole } from '../types'; +import { BIP32Path } from '@cardano-sdk/crypto'; export const harden = (num: number): number => 0x80_00_00_00 + num; @@ -37,3 +38,32 @@ export const deriveAccountPrivateKey = async ({ harden(CardanoKeyConst.COIN_TYPE), harden(accountIndex) ]); + +/** + * Constructs the hardened derivation path of the payment key for the + * given grouped address of an HD wallet as specified in CIP 1852 + * https://cips.cardano.org/cips/cip1852/ + */ +export const paymentKeyPathFromGroupedAddress = (address: GroupedAddress): BIP32Path => [ + harden(CardanoKeyConst.PURPOSE), + harden(CardanoKeyConst.COIN_TYPE), + harden(address.accountIndex), + address.type, + address.index +]; + +/** + * Constructs the hardened derivation path of the staking key for the + * given grouped address of an HD wallet as specified in CIP 11 + * https://cips.cardano.org/cips/cip11/ + */ +export const stakeKeyPathFromGroupedAddress = (address: GroupedAddress | undefined): BIP32Path | null => { + if (!address?.stakeKeyDerivationPath) return null; + return [ + harden(CardanoKeyConst.PURPOSE), + harden(CardanoKeyConst.COIN_TYPE), + harden(address.accountIndex), + address.stakeKeyDerivationPath.role, + address.stakeKeyDerivationPath.index + ]; +}; diff --git a/packages/key-management/test/util/key.test.ts b/packages/key-management/test/util/key.test.ts new file mode 100644 index 00000000000..bf63184d041 --- /dev/null +++ b/packages/key-management/test/util/key.test.ts @@ -0,0 +1,47 @@ +import { AddressType, GroupedAddress, KeyRole } from '../../src'; +import { Cardano } from '@cardano-sdk/core'; +import { paymentKeyPathFromGroupedAddress, stakeKeyPathFromGroupedAddress } from '../../src/util'; + +export const paymentAddress = Cardano.PaymentAddress( + 'addr1qxdtr6wjx3kr7jlrvrfzhrh8w44qx9krcxhvu3e79zr7497tpmpxjfyhk3vwg6qjezjmlg5nr5dzm9j6nxyns28vsy8stu5lh6' +); + +const rewardKey = 'stake1u89sasnfyjtmgk8ydqfv3fdl52f36x3djedfnzfc9rkgzrcss5vgr'; + +export const rewardAccount = Cardano.RewardAccount(rewardKey); + +const stakeKeyDerivationPath = { + index: 0, + role: KeyRole.Stake +}; + +const knownAddress: GroupedAddress = { + accountIndex: 0, + address: paymentAddress, + index: 0, + networkId: Cardano.NetworkId.Testnet, + rewardAccount, + stakeKeyDerivationPath, + type: AddressType.Internal +}; + +const knownAddressKeyPath = [2_147_485_500, 2_147_485_463, 2_147_483_648, 1, 0]; +const knownAddressStakeKeyPath = [2_147_485_500, 2_147_485_463, 2_147_483_648, 2, 0]; + +describe('key utils', () => { + describe('paymentKeyPathFromGroupedAddress', () => { + it('returns a hardened BIP32 payment key path', () => { + expect(paymentKeyPathFromGroupedAddress(knownAddress)).toEqual(knownAddressKeyPath); + }); + }); + describe('stakeKeyPathFromGroupedAddress', () => { + it('returns null when given an undefined stakeKeyDerivationPath', async () => { + const knownAddressClone = { ...knownAddress }; + delete knownAddressClone.stakeKeyDerivationPath; + expect(stakeKeyPathFromGroupedAddress(knownAddressClone)).toEqual(null); + }); + it('returns a hardened BIP32 stake key path', () => { + expect(stakeKeyPathFromGroupedAddress(knownAddress)).toEqual(knownAddressStakeKeyPath); + }); + }); +}); diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 1b78bc68baa..9ff70f95d9d 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -67,6 +67,7 @@ "@cardano-sdk/crypto": "workspace:~", "@cardano-sdk/dapp-connector": "workspace:~", "@cardano-sdk/hardware-ledger": "workspace:~", + "@cardano-sdk/hardware-trezor": "workspace:~", "@cardano-sdk/input-selection": "workspace:~", "@cardano-sdk/key-management": "workspace:~", "@cardano-sdk/tx-construction": "workspace:~", diff --git a/packages/wallet/src/services/KeyAgent/restoreKeyAgent.ts b/packages/wallet/src/services/KeyAgent/restoreKeyAgent.ts index bdacb137f80..abed8692f94 100644 --- a/packages/wallet/src/services/KeyAgent/restoreKeyAgent.ts +++ b/packages/wallet/src/services/KeyAgent/restoreKeyAgent.ts @@ -12,12 +12,12 @@ import { SerializableKeyAgentData, SerializableLedgerKeyAgentData, SerializableTrezorKeyAgentData, - TrezorKeyAgent, errors, util } from '@cardano-sdk/key-management'; import { LedgerKeyAgent } from '@cardano-sdk/hardware-ledger'; import { Logger } from 'ts-log'; +import { TrezorKeyAgent } from '@cardano-sdk/hardware-trezor'; // TODO: use this type as 2nd parameter of restoreKeyAgent export interface RestoreInMemoryKeyAgentProps { diff --git a/packages/wallet/src/tsconfig.json b/packages/wallet/src/tsconfig.json index e9507baae86..7d4ef71c84d 100644 --- a/packages/wallet/src/tsconfig.json +++ b/packages/wallet/src/tsconfig.json @@ -30,6 +30,9 @@ }, { "path": "../../hardware-ledger/src" + }, + { + "path": "../../hardware-trezor/src" } ] } diff --git a/packages/wallet/test/README.md b/packages/wallet/test/README.md index 2923b764153..04266ad29ad 100644 --- a/packages/wallet/test/README.md +++ b/packages/wallet/test/README.md @@ -1,10 +1,16 @@ # Cardano JS SDK | Wallet | Test -## `yarn test:hw` - Running these suites requires both a supported Ledger and Trezor device to be plugged in via USB. You may need to install udev rules, if running on Linux, which can be done by using the script documented in [Download and Install Ledger Live docs], and via the [Trezor Suite] UI. -[download and install ledger live docs]: https://support.ledger.com/hc/en-us/articles/4404389606417-Download-and-install-Ledger-Live?docs=true -[trezor suite]: https://trezor.io/trezor-suite +[Download and Install Ledger Live docs]: https://support.ledger.com/hc/en-us/articles/4404389606417-Download-and-install-Ledger-Live?docs=true +[Trezor Suite]: https://trezor.io/trezor-suite + +## Ledger HW Tests + +`yarn test:hw:ledger` + +## Trezor HW Tests + +`yarn test:hw:trezor` diff --git a/packages/wallet/test/hardware/trezor/TrezorKeyAgent.integration.test.ts b/packages/wallet/test/hardware/trezor/TrezorKeyAgent.integration.test.ts index 35274283cd3..cfd7e2c6e01 100644 --- a/packages/wallet/test/hardware/trezor/TrezorKeyAgent.integration.test.ts +++ b/packages/wallet/test/hardware/trezor/TrezorKeyAgent.integration.test.ts @@ -1,7 +1,8 @@ import * as Crypto from '@cardano-sdk/crypto'; import { Cardano } from '@cardano-sdk/core'; -import { CommunicationType, KeyAgent, TrezorKeyAgent, util } from '@cardano-sdk/key-management'; +import { CommunicationType, KeyAgent, util } from '@cardano-sdk/key-management'; import { ObservableWallet, PersonalWallet, restoreKeyAgent, setupWallet } from '../../../src'; +import { TrezorKeyAgent } from '@cardano-sdk/hardware-trezor'; import { createStubStakePoolProvider, mockProviders } from '@cardano-sdk/util-dev'; import { firstValueFrom } from 'rxjs'; import { dummyLogger as logger } from 'ts-log'; diff --git a/packages/wallet/test/hardware/trezor/TrezorKeyAgent.test.ts b/packages/wallet/test/hardware/trezor/TrezorKeyAgent.test.ts index 2eef1aa90b5..c115c7d1390 100644 --- a/packages/wallet/test/hardware/trezor/TrezorKeyAgent.test.ts +++ b/packages/wallet/test/hardware/trezor/TrezorKeyAgent.test.ts @@ -1,17 +1,12 @@ import * as Crypto from '@cardano-sdk/crypto'; -import { - AddressType, - CommunicationType, - GroupedAddress, - SerializableTrezorKeyAgentData, - TrezorKeyAgent, - util -} from '@cardano-sdk/key-management'; +import { AddressType, CommunicationType, SerializableTrezorKeyAgentData, util } from '@cardano-sdk/key-management'; import { AssetId, createStubStakePoolProvider, mockProviders as mocks } from '@cardano-sdk/util-dev'; import { Cardano } from '@cardano-sdk/core'; +import { InitializeTxProps, InitializeTxResult } from '@cardano-sdk/tx-construction'; import { PersonalWallet, setupWallet } from '../../../src'; +import { TrezorKeyAgent } from '@cardano-sdk/hardware-trezor'; import { dummyLogger as logger } from 'ts-log'; -import { mockKeyAgentDependencies, stakeKeyDerivationPath } from '../../../../key-management/test/mocks'; +import { mockKeyAgentDependencies } from '../../../../key-management/test/mocks'; describe('TrezorKeyAgent', () => { let keyAgent: TrezorKeyAgent; @@ -30,37 +25,28 @@ describe('TrezorKeyAgent', () => { ({ keyAgent, wallet } = await setupWallet({ bip32Ed25519: new Crypto.SodiumBip32Ed25519(), - createKeyAgent: async (dependencies) => { - const trezorKeyAgent = await TrezorKeyAgent.createWithDevice( + createKeyAgent: async (dependencies) => + await TrezorKeyAgent.createWithDevice( { chainId: Cardano.ChainIds.Preprod, trezorConfig }, dependencies - ); - const groupedAddress: GroupedAddress = { - accountIndex: 0, - address: mocks.utxo[0][0].address, - index: 0, - networkId: Cardano.NetworkId.Testnet, - rewardAccount: mocks.rewardAccount, - stakeKeyDerivationPath, - type: AddressType.External - }; - trezorKeyAgent.deriveAddress = jest.fn().mockResolvedValue(groupedAddress); - trezorKeyAgent.knownAddresses.push(groupedAddress); - return trezorKeyAgent; - }, + ), createWallet: async (trezorKeyAgent) => { + const { address, rewardAccount } = await trezorKeyAgent.deriveAddress( + { index: 0, type: AddressType.External }, + 0 + ); const assetProvider = mocks.mockAssetProvider(); const stakePoolProvider = createStubStakePoolProvider(); const networkInfoProvider = mocks.mockNetworkInfoProvider(); - const rewardsProvider = mocks.mockRewardsProvider(); - const chainHistoryProvider = mocks.mockChainHistoryProvider(); - const utxoProvider = mocks.mockUtxoProvider(); + const utxoProvider = mocks.mockUtxoProvider({ address }); + const rewardsProvider = mocks.mockRewardsProvider({ rewardAccount }); + const chainHistoryProvider = mocks.mockChainHistoryProvider({ rewardAccount }); const asyncKeyAgent = util.createAsyncKeyAgent(trezorKeyAgent); return new PersonalWallet( - { name: 'Trezor Wallet' }, + { name: 'HW Wallet' }, { assetProvider, chainHistoryProvider, @@ -114,7 +100,7 @@ describe('TrezorKeyAgent', () => { expect(typeof keyAgent.extendedAccountPublicKey).toBe('string'); }); - test('sign tx', async () => { + describe('sign transaction', () => { const outputs = [ { address: Cardano.PaymentAddress( @@ -132,15 +118,31 @@ describe('TrezorKeyAgent', () => { } } ]; - const props = { + const props: InitializeTxProps = { outputs: new Set(outputs) }; - const txInternals = await wallet.initializeTx(props); - const signatures = await keyAgent.signTransaction({ - body: txInternals.body, - hash: txInternals.hash + let txInternals: InitializeTxResult; + + beforeAll(async () => { + txInternals = await wallet.initializeTx(props); + }); + + it('successfully signs a transaction with assets', async () => { + const signatures = await keyAgent.signTransaction({ + body: txInternals.body, + hash: txInternals.hash + }); + expect(signatures.size).toBe(2); + }); + + it('throws if signed transaction hash doesnt match hash computed by the wallet', async () => { + await expect( + keyAgent.signTransaction({ + body: txInternals.body, + hash: 'non-matching' as unknown as Cardano.TransactionId + }) + ).rejects.toThrow(); }); - expect(signatures.size).toBe(2); }); describe('serializableData', () => { diff --git a/packages/wallet/test/tsconfig.json b/packages/wallet/test/tsconfig.json index 40b78f04175..72bdd0ecbd9 100644 --- a/packages/wallet/test/tsconfig.json +++ b/packages/wallet/test/tsconfig.json @@ -41,6 +41,9 @@ }, { "path": "../../hardware-ledger/src" + }, + { + "path": "../../hardware-trezor/src" } ] } diff --git a/yarn.lock b/yarn.lock index 8e083e71bd9..7eeddc758d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3204,6 +3204,7 @@ __metadata: "@cardano-sdk/crypto": "workspace:~" "@cardano-sdk/dapp-connector": "workspace:~" "@cardano-sdk/hardware-ledger": "workspace:~" + "@cardano-sdk/hardware-trezor": "workspace:~" "@cardano-sdk/input-selection": "workspace:~" "@cardano-sdk/key-management": "workspace:~" "@cardano-sdk/ogmios": "workspace:~" @@ -3373,6 +3374,30 @@ __metadata: languageName: unknown linkType: soft +"@cardano-sdk/hardware-trezor@workspace:packages/hardware-trezor, @cardano-sdk/hardware-trezor@workspace:~": + version: 0.0.0-use.local + resolution: "@cardano-sdk/hardware-trezor@workspace:packages/hardware-trezor" + dependencies: + "@cardano-sdk/core": "workspace:~" + "@cardano-sdk/crypto": "workspace:~" + "@cardano-sdk/key-management": "workspace:~" + "@cardano-sdk/tx-construction": "workspace:~" + "@cardano-sdk/util": "workspace:~" + "@trezor/connect": 9.0.11 + "@trezor/connect-web": 9.0.11 + eslint: ^7.32.0 + jest: ^28.1.3 + lodash: ^4.17.21 + madge: ^5.0.1 + npm-run-all: ^4.1.5 + shx: ^0.3.3 + ts-custom-error: ^3.2.0 + ts-jest: ^28.0.7 + ts-log: 2.2.4 + typescript: ^4.7.4 + languageName: unknown + linkType: soft + "@cardano-sdk/input-selection@workspace:packages/input-selection, @cardano-sdk/input-selection@workspace:~": version: 0.0.0-use.local resolution: "@cardano-sdk/input-selection@workspace:packages/input-selection" @@ -3620,6 +3645,7 @@ __metadata: "@cardano-sdk/crypto": "workspace:~" "@cardano-sdk/dapp-connector": "workspace:~" "@cardano-sdk/hardware-ledger": "workspace:~" + "@cardano-sdk/hardware-trezor": "workspace:~" "@cardano-sdk/input-selection": "workspace:~" "@cardano-sdk/key-management": "workspace:~" "@cardano-sdk/ogmios": "workspace:~"