From e3d350354314251267e8864d20d749333ebae213 Mon Sep 17 00:00:00 2001 From: John Oshalusi Date: Wed, 26 Feb 2025 11:34:27 +0100 Subject: [PATCH] feat: sign multi-sig transaction with trezor --- packages/core/src/Cardano/util/index.ts | 1 + .../core/src/Cardano/util/isScriptAddress.ts | 9 ++ .../hardware-ledger/src/transformers/txOut.ts | 12 +- .../hardware-trezor/src/TrezorKeyAgent.ts | 37 +++++- .../hardware-trezor/src/transformers/txOut.ts | 3 +- .../trezor/TrezorSharedWalletKeyAgent.test.ts | 117 ++++++++++++++++++ 6 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/Cardano/util/isScriptAddress.ts create mode 100644 packages/wallet/test/hardware/trezor/TrezorSharedWalletKeyAgent.test.ts diff --git a/packages/core/src/Cardano/util/index.ts b/packages/core/src/Cardano/util/index.ts index fc740d36280..3ce211e98d4 100644 --- a/packages/core/src/Cardano/util/index.ts +++ b/packages/core/src/Cardano/util/index.ts @@ -5,3 +5,4 @@ export * from './resolveInputValue'; export * from './phase2Validation'; export * from './addressesShareAnyKey'; export * from './plutusDataUtils'; +export * from './isScriptAddress'; diff --git a/packages/core/src/Cardano/util/isScriptAddress.ts b/packages/core/src/Cardano/util/isScriptAddress.ts new file mode 100644 index 00000000000..d5a9a1c7844 --- /dev/null +++ b/packages/core/src/Cardano/util/isScriptAddress.ts @@ -0,0 +1,9 @@ +import { Address, CredentialType } from '../Address'; + +// TODO: Would like to verify if this is needed before writing test for it +export const isScriptAddress = (address: string): boolean => { + const baseAddress = Address.fromBech32(address).asBase(); + const paymentCredential = baseAddress?.getPaymentCredential(); + const stakeCredential = baseAddress?.getStakeCredential(); + return paymentCredential?.type === CredentialType.ScriptHash && stakeCredential?.type === CredentialType.ScriptHash; +}; diff --git a/packages/hardware-ledger/src/transformers/txOut.ts b/packages/hardware-ledger/src/transformers/txOut.ts index 9422a43eb0a..0d348d9d275 100644 --- a/packages/hardware-ledger/src/transformers/txOut.ts +++ b/packages/hardware-ledger/src/transformers/txOut.ts @@ -5,16 +5,6 @@ import { LedgerTxTransformerContext } from '../types'; import { mapTokenMap } from './assets'; import { util } from '@cardano-sdk/key-management'; -const isScriptAddress = (address: string): boolean => { - const baseAddress = Cardano.Address.fromBech32(address).asBase(); - const paymentCredential = baseAddress?.getPaymentCredential(); - const stakeCredential = baseAddress?.getStakeCredential(); - return ( - paymentCredential?.type === Cardano.CredentialType.ScriptHash && - stakeCredential?.type === Cardano.CredentialType.ScriptHash - ); -}; - const toInlineDatum: Transform = (datum) => ({ datumHex: Serialization.PlutusData.fromCore(datum).toCbor(), type: Ledger.DatumType.INLINE @@ -30,7 +20,7 @@ const toDestination: Transform { const knownAddress = context?.knownAddresses.find((address) => address.address === txOut.address); - const isScriptWallet = isScriptAddress(txOut.address); + const isScriptWallet = Cardano.util.isScriptAddress(txOut.address); if (knownAddress && !isScriptWallet) { const paymentKeyPath = util.paymentKeyPathFromGroupedAddress(knownAddress); diff --git a/packages/hardware-trezor/src/TrezorKeyAgent.ts b/packages/hardware-trezor/src/TrezorKeyAgent.ts index 20ad6e01656..17ce1d4b08e 100644 --- a/packages/hardware-trezor/src/TrezorKeyAgent.ts +++ b/packages/hardware-trezor/src/TrezorKeyAgent.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as Crypto from '@cardano-sdk/crypto'; import * as Trezor from '@trezor/connect'; +import { BIP32Path } from '@cardano-sdk/crypto'; import { Cardano, NotImplementedError, Serialization } from '@cardano-sdk/core'; import { CardanoKeyConst, @@ -9,6 +10,7 @@ import { KeyAgentDependencies, KeyAgentType, KeyPurpose, + KeyRole, SerializableTrezorKeyAgentData, SignBlobResult, SignTransactionContext, @@ -68,6 +70,10 @@ const containsOnlyScriptHashCredentials = (tx: Omit !withdrawal.scriptHash); }; +const multiSigWitnessPaths: BIP32Path[] = [ + util.accountKeyDerivationPathToBip32Path(0, { index: 0, role: KeyRole.External }, KeyPurpose.MULTI_SIG) +]; + const isMultiSig = (tx: Omit): boolean => { const allThirdPartyInputs = !tx.inputs.some((input) => input.path); // Trezor doesn't allow change outputs to address controlled by your keys and instead you have to use script address for change out @@ -116,6 +122,21 @@ export class TrezorKeyAgent extends KeyAgentBase { // Show Trezor Suite popup. Disabled for node based apps popup: communicationType !== CommunicationType.Node && !silentMode }); + + trezorConnect.on(Trezor.UI_EVENT, (event) => { + // React on ui-request_passphrase event + if (event.type === Trezor.UI.REQUEST_PASSPHRASE && event.payload.device) { + trezorConnect.uiResponse({ + payload: { + passphraseOnDevice: true, + save: true, + value: '' + }, + type: Trezor.UI.RECEIVE_PASSPHRASE + }); + } + }); + return true; } catch (error: any) { if (error.code === 'Init_AlreadyInitialized') return true; @@ -235,14 +256,20 @@ export class TrezorKeyAgent extends KeyAgentBase { const trezorConnect = getTrezorConnect(this.#communicationType); const result = await trezorConnect.cardanoSignTransaction({ ...trezorTxData, + ...(signingMode === Trezor.PROTO.CardanoTxSigningMode.MULTISIG_TRANSACTION && { + additionalWitnessRequests: multiSigWitnessPaths + }), signingMode }); - const expectedPublicKeys = await Promise.all( - util - .ownSignatureKeyPaths(body, knownAddresses, txInKeyPathMap) - .map((derivationPath) => this.derivePublicKey(derivationPath)) - ); + const expectedPublicKeys = + signingMode === Trezor.PROTO.CardanoTxSigningMode.MULTISIG_TRANSACTION + ? [await this.derivePublicKey({ index: 0, role: KeyRole.External })] + : await Promise.all( + util + .ownSignatureKeyPaths(body, knownAddresses, txInKeyPathMap) + .map((derivationPath) => this.derivePublicKey(derivationPath)) + ); if (!result.success) { throw new errors.TransportError('Failed to export extended account public key', result.payload); diff --git a/packages/hardware-trezor/src/transformers/txOut.ts b/packages/hardware-trezor/src/transformers/txOut.ts index 38524a222ec..578aadad0ca 100644 --- a/packages/hardware-trezor/src/transformers/txOut.ts +++ b/packages/hardware-trezor/src/transformers/txOut.ts @@ -10,8 +10,9 @@ const toDestination: Transform { const knownAddress = context?.knownAddresses.find((address: GroupedAddress) => address.address === txOut.address); + const isScriptWallet = Cardano.util.isScriptAddress(txOut.address); - if (!knownAddress) { + if (!knownAddress || isScriptWallet) { return { address: txOut.address }; diff --git a/packages/wallet/test/hardware/trezor/TrezorSharedWalletKeyAgent.test.ts b/packages/wallet/test/hardware/trezor/TrezorSharedWalletKeyAgent.test.ts new file mode 100644 index 00000000000..e68a4fef6a0 --- /dev/null +++ b/packages/wallet/test/hardware/trezor/TrezorSharedWalletKeyAgent.test.ts @@ -0,0 +1,117 @@ +import * as Crypto from '@cardano-sdk/crypto'; +import { BaseWallet, createSharedWallet } from '../../../src'; +import { Cardano } from '@cardano-sdk/core'; +import { CommunicationType, KeyPurpose, KeyRole, util } from '@cardano-sdk/key-management'; +import { InitializeTxProps, InitializeTxResult } from '@cardano-sdk/tx-construction'; +import { TrezorKeyAgent } from '@cardano-sdk/hardware-trezor'; +import { dummyLogger as logger } from 'ts-log'; +import { mockProviders as mocks } from '@cardano-sdk/util-dev'; + +describe('TrezorSharedWalletKeyAgent', () => { + let wallet: BaseWallet; + let trezorKeyAgent: TrezorKeyAgent; + let txSubmitProvider: mocks.TxSubmitProviderStub; + + const trezorConfig = { + communicationType: CommunicationType.Node, + manifest: { + appUrl: 'https://your.application.com', + email: 'email@developer.com' + } + }; + + beforeAll(async () => { + txSubmitProvider = mocks.mockTxSubmitProvider(); + trezorKeyAgent = await TrezorKeyAgent.createWithDevice( + { + chainId: Cardano.ChainIds.Preprod, + purpose: KeyPurpose.MULTI_SIG, + trezorConfig + }, + { + bip32Ed25519: await Crypto.SodiumBip32Ed25519.create(), + logger + } + ); + + const walletPubKey = await trezorKeyAgent.derivePublicKey({ index: 0, role: KeyRole.External }); + const walletKeyHash = trezorKeyAgent.bip32Ed25519.getPubKeyHash(walletPubKey); + + const walletStakePubKey = await trezorKeyAgent.derivePublicKey({ index: 0, role: KeyRole.Stake }); + const walletStakeKeyHash = trezorKeyAgent.bip32Ed25519.getPubKeyHash(walletStakePubKey); + + const paymentScript: Cardano.NativeScript = { + __type: Cardano.ScriptType.Native, + kind: Cardano.NativeScriptKind.RequireAnyOf, + scripts: [ + { + __type: Cardano.ScriptType.Native, + keyHash: walletKeyHash, + kind: Cardano.NativeScriptKind.RequireSignature + }, + { + __type: Cardano.ScriptType.Native, + keyHash: Crypto.Ed25519KeyHashHex('b275b08c999097247f7c17e77007c7010cd19f20cc086ad99d398539'), + kind: Cardano.NativeScriptKind.RequireSignature + } + ] + }; + + const stakingScript: Cardano.NativeScript = { + __type: Cardano.ScriptType.Native, + kind: Cardano.NativeScriptKind.RequireAnyOf, + scripts: [ + { + __type: Cardano.ScriptType.Native, + keyHash: walletStakeKeyHash, + kind: Cardano.NativeScriptKind.RequireSignature + }, + { + __type: Cardano.ScriptType.Native, + keyHash: Crypto.Ed25519KeyHashHex('b275b08c999097247f7c17e77007c7010cd19f20cc086ad99d398539'), + kind: Cardano.NativeScriptKind.RequireSignature + } + ] + }; + + wallet = createSharedWallet( + { name: 'Shared HW Wallet' }, + { + assetProvider: mocks.mockAssetProvider(), + chainHistoryProvider: mocks.mockChainHistoryProvider(), + logger, + networkInfoProvider: mocks.mockNetworkInfoProvider(), + paymentScript, + rewardAccountInfoProvider: mocks.mockRewardAccountInfoProvider(), + rewardsProvider: mocks.mockRewardsProvider(), + stakingScript, + // txSubmitProvider: mocks.mockTxSubmitProvider(), + txSubmitProvider, + utxoProvider: mocks.mockUtxoProvider(), + witnesser: util.createBip32Ed25519Witnesser(util.createAsyncKeyAgent(trezorKeyAgent)) + } + ); + }); + + afterAll(() => wallet.shutdown()); + + describe('Sign Transaction', () => { + let props: InitializeTxProps; + let txInternals: InitializeTxResult; + const simpleOutput = { + address: Cardano.PaymentAddress( + 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w' + ), + value: { coins: 11_111_111n } + }; + + it('should sign simple multi-sig transaction', async () => { + props = { + outputs: new Set([simpleOutput]) + }; + txInternals = await wallet.initializeTx(props); + const witnessedTx = await wallet.finalizeTx({ tx: txInternals }); + expect(witnessedTx.witness.signatures.size).toBe(1); + }); + }); +});