From 06289fb5fc81280ca808f52d30e93e383aa55e5a Mon Sep 17 00:00:00 2001 From: John Oshalusi Date: Thu, 20 Feb 2025 18:05:01 +0100 Subject: [PATCH] feat: sign multi-sig transaction with ledger --- .../hardware-ledger/src/LedgerKeyAgent.ts | 9 +- .../hardware-ledger/src/transformers/txOut.ts | 13 +- .../ledger/LedgerSharedWalletKeyAgent.test.ts | 112 ++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 packages/wallet/test/hardware/ledger/LedgerSharedWalletKeyAgent.test.ts diff --git a/packages/hardware-ledger/src/LedgerKeyAgent.ts b/packages/hardware-ledger/src/LedgerKeyAgent.ts index 5bfb7ec3cc1..12c792f9efc 100644 --- a/packages/hardware-ledger/src/LedgerKeyAgent.ts +++ b/packages/hardware-ledger/src/LedgerKeyAgent.ts @@ -322,6 +322,10 @@ const getDerivationPath = ( }; }; +const multiSigWitnessPaths: BIP32Path[] = [ + util.accountKeyDerivationPathToBip32Path(0, { index: 0, role: KeyRole.External }, KeyPurpose.MULTI_SIG) +]; + export class LedgerKeyAgent extends KeyAgentBase { readonly deviceConnection?: LedgerConnection; readonly #communicationType: CommunicationType; @@ -733,7 +737,10 @@ export class LedgerKeyAgent extends KeyAgentBase { tagCborSets: txBody.hasTaggedSets() }, signingMode, - tx: ledgerTxData + tx: ledgerTxData, + ...(signingMode === TransactionSigningMode.MULTISIG_TRANSACTION && { + additionalWitnessPaths: multiSigWitnessPaths + }) }); if (!areStringsEqualInConstantTime(result.txHashHex, hash)) { diff --git a/packages/hardware-ledger/src/transformers/txOut.ts b/packages/hardware-ledger/src/transformers/txOut.ts index a99e53f07cf..9422a43eb0a 100644 --- a/packages/hardware-ledger/src/transformers/txOut.ts +++ b/packages/hardware-ledger/src/transformers/txOut.ts @@ -5,6 +5,16 @@ 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 @@ -20,8 +30,9 @@ const toDestination: Transform { const knownAddress = context?.knownAddresses.find((address) => address.address === txOut.address); + const isScriptWallet = isScriptAddress(txOut.address); - if (knownAddress) { + if (knownAddress && !isScriptWallet) { const paymentKeyPath = util.paymentKeyPathFromGroupedAddress(knownAddress); const stakeKeyPath = util.stakeKeyPathFromGroupedAddress(knownAddress); diff --git a/packages/wallet/test/hardware/ledger/LedgerSharedWalletKeyAgent.test.ts b/packages/wallet/test/hardware/ledger/LedgerSharedWalletKeyAgent.test.ts new file mode 100644 index 00000000000..ea51979598e --- /dev/null +++ b/packages/wallet/test/hardware/ledger/LedgerSharedWalletKeyAgent.test.ts @@ -0,0 +1,112 @@ +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 { LedgerKeyAgent } from '@cardano-sdk/hardware-ledger'; +import { dummyLogger as logger } from 'ts-log'; +import { mockProviders as mocks } from '@cardano-sdk/util-dev'; + +describe('LedgerSharedWalletKeyAgent', () => { + let ledgerKeyAgent: LedgerKeyAgent; + let wallet: BaseWallet; + + beforeAll(async () => { + ledgerKeyAgent = await LedgerKeyAgent.createWithDevice( + { + chainId: Cardano.ChainIds.Preprod, + communicationType: CommunicationType.Node, + purpose: KeyPurpose.MULTI_SIG + }, + { bip32Ed25519: await Crypto.SodiumBip32Ed25519.create(), logger } + ); + }); + + afterAll(async () => { + await ledgerKeyAgent.deviceConnection?.transport.close(); + }); + + describe('signTransaction', () => { + let txInternals: InitializeTxResult; + + beforeAll(async () => { + const walletPubKey = await ledgerKeyAgent.derivePublicKey({ index: 0, role: KeyRole.External }); + const walletKeyHash = ledgerKeyAgent.bip32Ed25519.getPubKeyHash(walletPubKey); + + const walletStakePubKey = await ledgerKeyAgent.derivePublicKey({ index: 0, role: KeyRole.Stake }); + const walletStakeKeyHash = ledgerKeyAgent.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 + } + ] + }; + + const outputs: Cardano.TxOut[] = [ + { + address: Cardano.PaymentAddress( + 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w' + ), + scriptReference: paymentScript, + value: { coins: 11_111_111n } + } + ]; + const props: InitializeTxProps = { + outputs: new Set(outputs) + }; + + 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(), + utxoProvider: mocks.mockUtxoProvider(), + witnesser: util.createBip32Ed25519Witnesser(util.createAsyncKeyAgent(ledgerKeyAgent)) + } + ); + txInternals = await wallet.initializeTx(props); + }); + + afterAll(() => wallet.shutdown()); + + it('successfully signs a transaction', async () => { + const tx = await wallet.finalizeTx({ tx: txInternals }); + expect(tx.witness.signatures.size).toBe(1); + }); + }); +});