Skip to content

Commit d15a044

Browse files
authored
feat: sign multi-sig transaction with hw wallet (#1604)
1 parent 62010d2 commit d15a044

File tree

14 files changed

+368
-18
lines changed

14 files changed

+368
-18
lines changed

packages/core/src/Cardano/util/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './resolveInputValue';
55
export * from './phase2Validation';
66
export * from './addressesShareAnyKey';
77
export * from './plutusDataUtils';
8+
export * from './isScriptAddress';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Address, CredentialType, PaymentAddress } from '../Address';
2+
3+
export const isScriptAddress = (address: PaymentAddress): boolean => {
4+
const baseAddress = Address.fromBech32(address).asBase();
5+
const paymentCredential = baseAddress?.getPaymentCredential();
6+
const stakeCredential = baseAddress?.getStakeCredential();
7+
return paymentCredential?.type === CredentialType.ScriptHash && stakeCredential?.type === CredentialType.ScriptHash;
8+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { PaymentAddress } from '../../../src/Cardano';
2+
import { isScriptAddress } from '../../../src/Cardano/util';
3+
4+
describe('isScriptAddress', () => {
5+
it('returns false when it receives a non-script address', () => {
6+
const nonScriptAddress = PaymentAddress(
7+
'addr_test1qpfhhfy2qgls50r9u4yh0l7z67xpg0a5rrhkmvzcuqrd0znuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q9gw0lz'
8+
);
9+
expect(isScriptAddress(nonScriptAddress)).toBe(false);
10+
});
11+
12+
it('returns true when it receives a script address', () => {
13+
const scriptAddress = PaymentAddress(
14+
'addr_test1xr806j8xcq6cw6jjkzfxyewyue33zwnu4ajnu28hakp5fmc6gddlgeqee97vwdeafwrdgrtzp2rw8rlchjf25ld7r2ssptq3m9'
15+
);
16+
expect(isScriptAddress(scriptAddress)).toBe(true);
17+
});
18+
});

packages/hardware-ledger/src/LedgerKeyAgent.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ const getDerivationPath = (
322322
};
323323
};
324324

325+
const multiSigWitnessPaths: BIP32Path[] = [
326+
util.accountKeyDerivationPathToBip32Path(0, { index: 0, role: KeyRole.External }, KeyPurpose.MULTI_SIG)
327+
];
328+
325329
export class LedgerKeyAgent extends KeyAgentBase {
326330
readonly deviceConnection?: LedgerConnection;
327331
readonly #communicationType: CommunicationType;
@@ -733,7 +737,10 @@ export class LedgerKeyAgent extends KeyAgentBase {
733737
tagCborSets: txBody.hasTaggedSets()
734738
},
735739
signingMode,
736-
tx: ledgerTxData
740+
tx: ledgerTxData,
741+
...(signingMode === TransactionSigningMode.MULTISIG_TRANSACTION && {
742+
additionalWitnessPaths: multiSigWitnessPaths
743+
})
737744
});
738745

739746
if (!areStringsEqualInConstantTime(result.txHashHex, hash)) {

packages/hardware-ledger/src/transformers/txOut.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ const toDestination: Transform<Cardano.TxOut, Ledger.TxOutputDestination, Ledger
2020
context
2121
) => {
2222
const knownAddress = context?.knownAddresses.find((address) => address.address === txOut.address);
23+
const isScriptAddress = Cardano.util.isScriptAddress(txOut.address);
2324

24-
if (knownAddress) {
25+
if (knownAddress && !isScriptAddress) {
2526
const paymentKeyPath = util.paymentKeyPathFromGroupedAddress(knownAddress);
2627
const stakeKeyPath = util.stakeKeyPathFromGroupedAddress(knownAddress);
2728

packages/hardware-trezor/src/TrezorKeyAgent.ts

+30-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import * as Crypto from '@cardano-sdk/crypto';
33
import * as Trezor from '@trezor/connect';
4+
import { BIP32Path } from '@cardano-sdk/crypto';
45
import { Cardano, NotImplementedError, Serialization } from '@cardano-sdk/core';
56
import {
67
CardanoKeyConst,
@@ -9,6 +10,7 @@ import {
910
KeyAgentDependencies,
1011
KeyAgentType,
1112
KeyPurpose,
13+
KeyRole,
1214
SerializableTrezorKeyAgentData,
1315
SignBlobResult,
1416
SignTransactionContext,
@@ -68,6 +70,10 @@ const containsOnlyScriptHashCredentials = (tx: Omit<Trezor.CardanoSignTransactio
6870
return !tx.withdrawals?.some((withdrawal) => !withdrawal.scriptHash);
6971
};
7072

73+
const multiSigWitnessPaths: BIP32Path[] = [
74+
util.accountKeyDerivationPathToBip32Path(0, { index: 0, role: KeyRole.External }, KeyPurpose.MULTI_SIG)
75+
];
76+
7177
const isMultiSig = (tx: Omit<Trezor.CardanoSignTransaction, 'signingMode'>): boolean => {
7278
const allThirdPartyInputs = !tx.inputs.some((input) => input.path);
7379
// Trezor doesn't allow change outputs to address controlled by your keys and instead you have to use script address for change out
@@ -100,7 +106,8 @@ export class TrezorKeyAgent extends KeyAgentBase {
100106
manifest,
101107
communicationType,
102108
silentMode = false,
103-
lazyLoad = false
109+
lazyLoad = false,
110+
shouldHandlePassphrase = false
104111
}: TrezorConfig): Promise<boolean> {
105112
const trezorConnect = getTrezorConnect(communicationType);
106113
try {
@@ -116,6 +123,23 @@ export class TrezorKeyAgent extends KeyAgentBase {
116123
// Show Trezor Suite popup. Disabled for node based apps
117124
popup: communicationType !== CommunicationType.Node && !silentMode
118125
});
126+
127+
if (shouldHandlePassphrase) {
128+
trezorConnect.on(Trezor.UI_EVENT, (event) => {
129+
// React on ui-request_passphrase event
130+
if (event.type === Trezor.UI.REQUEST_PASSPHRASE && event.payload.device) {
131+
trezorConnect.uiResponse({
132+
payload: {
133+
passphraseOnDevice: true,
134+
save: true,
135+
value: ''
136+
},
137+
type: Trezor.UI.RECEIVE_PASSPHRASE
138+
});
139+
}
140+
});
141+
}
142+
119143
return true;
120144
} catch (error: any) {
121145
if (error.code === 'Init_AlreadyInitialized') return true;
@@ -215,7 +239,7 @@ export class TrezorKeyAgent extends KeyAgentBase {
215239

216240
async signTransaction(
217241
txBody: Serialization.TransactionBody,
218-
{ knownAddresses, txInKeyPathMap }: SignTransactionContext
242+
{ knownAddresses, txInKeyPathMap, scripts }: SignTransactionContext
219243
): Promise<Cardano.Signatures> {
220244
try {
221245
await this.isTrezorInitialized;
@@ -235,12 +259,15 @@ export class TrezorKeyAgent extends KeyAgentBase {
235259
const trezorConnect = getTrezorConnect(this.#communicationType);
236260
const result = await trezorConnect.cardanoSignTransaction({
237261
...trezorTxData,
262+
...(signingMode === Trezor.PROTO.CardanoTxSigningMode.MULTISIG_TRANSACTION && {
263+
additionalWitnessRequests: multiSigWitnessPaths
264+
}),
238265
signingMode
239266
});
240267

241268
const expectedPublicKeys = await Promise.all(
242269
util
243-
.ownSignatureKeyPaths(body, knownAddresses, txInKeyPathMap)
270+
.ownSignatureKeyPaths(body, knownAddresses, txInKeyPathMap, undefined, scripts)
244271
.map((derivationPath) => this.derivePublicKey(derivationPath))
245272
);
246273

packages/hardware-trezor/src/transformers/txOut.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ const toDestination: Transform<Cardano.TxOut, TrezorTxOutputDestination, TrezorT
1010
context
1111
) => {
1212
const knownAddress = context?.knownAddresses.find((address: GroupedAddress) => address.address === txOut.address);
13+
const isScriptAddress = Cardano.util.isScriptAddress(txOut.address);
1314

14-
if (!knownAddress) {
15+
if (!knownAddress || isScriptAddress) {
1516
return {
1617
address: txOut.address
1718
};

packages/key-management/src/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export interface TrezorConfig {
9292
email: string;
9393
appUrl: string;
9494
};
95+
/** When set to true, Trezor automatically handle passphrase entry by forcing it to occur on the device */
96+
shouldHandlePassphrase?: boolean;
9597
}
9698

9799
export interface SerializableKeyAgentDataBase {

packages/key-management/src/util/ownSignatureKeyPaths.ts

+17-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Crypto from '@cardano-sdk/crypto';
2-
import { AccountKeyDerivationPath, GroupedAddress, TxInId, TxInKeyPathMap } from '../types';
2+
import { AccountKeyDerivationPath, GroupedAddress, KeyRole, TxInId, TxInKeyPathMap } from '../types';
33
import { Cardano } from '@cardano-sdk/core';
44
import { DREP_KEY_DERIVATION_PATH } from './key';
55
import { Ed25519KeyHashHex } from '@cardano-sdk/crypto';
@@ -300,15 +300,24 @@ const checkStakeCredential = (address: GroupedAddress, keyHash: Crypto.Ed25519Ke
300300
? { derivationPaths: [address.stakeKeyDerivationPath], requiresForeignSignatures: false }
301301
: { derivationPaths: [], requiresForeignSignatures: true };
302302

303-
const checkPaymentCredential = (address: GroupedAddress, keyHash: Crypto.Ed25519KeyHashHex): SignatureCheck => {
303+
const checkPaymentCredential = (address: GroupedAddress, keyHash: Crypto.Ed25519KeyHashHex) => {
304304
const paymentCredential = Cardano.Address.fromBech32(address.address)?.asBase()?.getPaymentCredential();
305-
return paymentCredential?.type === Cardano.CredentialType.KeyHash &&
305+
if (
306+
paymentCredential?.type === Cardano.CredentialType.ScriptHash &&
306307
paymentCredential.hash === Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(keyHash)
307-
? {
308-
derivationPaths: [{ index: address.index, role: Number(address.type) }],
309-
requiresForeignSignatures: false
310-
}
311-
: { derivationPaths: [], requiresForeignSignatures: true };
308+
)
309+
return {
310+
derivationPaths: [{ index: address.index, role: Number(address.type) }],
311+
requiresForeignSignatures: false
312+
};
313+
314+
if (paymentCredential?.type === Cardano.CredentialType.ScriptHash) {
315+
return {
316+
derivationPaths: [{ index: address.index, role: KeyRole.External }],
317+
requiresForeignSignatures: false
318+
};
319+
}
320+
return { derivationPaths: [], requiresForeignSignatures: true };
312321
};
313322

314323
const combineSignatureChecks = (a: SignatureCheck, b: SignatureCheck): SignatureCheck => ({

packages/key-management/test/util/ownSignaturePaths.test.ts

+41-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const createGroupedAddress = (
3333
rewardAccount: Cardano.RewardAccount,
3434
type: AddressType,
3535
index: number,
36-
stakeKeyDerivationPath: AccountKeyDerivationPath
36+
stakeKeyDerivationPath?: AccountKeyDerivationPath
3737
) =>
3838
({
3939
...createBaseGroupedAddress(address, rewardAccount, type, index),
@@ -633,5 +633,45 @@ describe('KeyManagement.util.ownSignaturePaths', () => {
633633

634634
expect(util.ownSignatureKeyPaths(txBody, [knownAddress1], {}, undefined, scripts)).toEqual([]);
635635
});
636+
it('includes derivation paths for multi-signature native scripts', async () => {
637+
const scriptAddress = Cardano.PaymentAddress(
638+
'addr_test1xr806j8xcq6cw6jjkzfxyewyue33zwnu4ajnu28hakp5fmc6gddlgeqee97vwdeafwrdgrtzp2rw8rlchjf25ld7r2ssptq3m9'
639+
);
640+
const scriptRewardAccount = Cardano.RewardAccount(
641+
'stake_test17qdyxkl5vsvujlx8xu75hpk5p43q4phr3lutey420klp4gg7zmhrn'
642+
);
643+
const txBody: Cardano.TxBody = {
644+
fee: BigInt(0),
645+
inputs: [{}, {}, {}] as Cardano.TxIn[],
646+
outputs: []
647+
};
648+
649+
const scripts: Cardano.Script[] = [
650+
{
651+
__type: Cardano.ScriptType.Native,
652+
kind: Cardano.NativeScriptKind.RequireAnyOf,
653+
scripts: [
654+
{
655+
__type: Cardano.ScriptType.Native,
656+
keyHash: Ed25519KeyHashHex('b498c0eaceb9a8c7c829d36fc84e892113c9d2636b53b0636d7518b4'),
657+
kind: Cardano.NativeScriptKind.RequireSignature
658+
},
659+
{
660+
__type: Cardano.ScriptType.Native,
661+
keyHash: Ed25519KeyHashHex(otherStakeKeyHash),
662+
kind: Cardano.NativeScriptKind.RequireSignature
663+
}
664+
]
665+
}
666+
];
667+
668+
const knownAddress = createGroupedAddress(scriptAddress, scriptRewardAccount, AddressType.External, 0);
669+
expect(util.ownSignatureKeyPaths(txBody, [knownAddress], {}, undefined, scripts)).toEqual([
670+
{
671+
index: 0,
672+
role: KeyRole.External
673+
}
674+
]);
675+
});
636676
});
637677
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import * as Crypto from '@cardano-sdk/crypto';
2+
import { BaseWallet, createSharedWallet } from '../../../src';
3+
import { Cardano } from '@cardano-sdk/core';
4+
import { CommunicationType, KeyPurpose, KeyRole, util } from '@cardano-sdk/key-management';
5+
import { InitializeTxProps, InitializeTxResult } from '@cardano-sdk/tx-construction';
6+
import { LedgerKeyAgent } from '@cardano-sdk/hardware-ledger';
7+
import { dummyLogger as logger } from 'ts-log';
8+
import { mockProviders as mocks } from '@cardano-sdk/util-dev';
9+
10+
describe('LedgerSharedWalletKeyAgent', () => {
11+
let ledgerKeyAgent: LedgerKeyAgent;
12+
let wallet: BaseWallet;
13+
14+
beforeAll(async () => {
15+
ledgerKeyAgent = await LedgerKeyAgent.createWithDevice(
16+
{
17+
chainId: Cardano.ChainIds.Preprod,
18+
communicationType: CommunicationType.Node,
19+
purpose: KeyPurpose.MULTI_SIG
20+
},
21+
{ bip32Ed25519: await Crypto.SodiumBip32Ed25519.create(), logger }
22+
);
23+
});
24+
25+
afterAll(async () => {
26+
await ledgerKeyAgent.deviceConnection?.transport.close();
27+
});
28+
29+
describe('signTransaction', () => {
30+
let txInternals: InitializeTxResult;
31+
32+
beforeAll(async () => {
33+
const walletPubKey = await ledgerKeyAgent.derivePublicKey({ index: 0, role: KeyRole.External });
34+
const walletKeyHash = ledgerKeyAgent.bip32Ed25519.getPubKeyHash(walletPubKey);
35+
36+
const walletStakePubKey = await ledgerKeyAgent.derivePublicKey({ index: 0, role: KeyRole.Stake });
37+
const walletStakeKeyHash = ledgerKeyAgent.bip32Ed25519.getPubKeyHash(walletStakePubKey);
38+
39+
const paymentScript: Cardano.NativeScript = {
40+
__type: Cardano.ScriptType.Native,
41+
kind: Cardano.NativeScriptKind.RequireAnyOf,
42+
scripts: [
43+
{
44+
__type: Cardano.ScriptType.Native,
45+
keyHash: walletKeyHash,
46+
kind: Cardano.NativeScriptKind.RequireSignature
47+
},
48+
{
49+
__type: Cardano.ScriptType.Native,
50+
keyHash: Crypto.Ed25519KeyHashHex('b275b08c999097247f7c17e77007c7010cd19f20cc086ad99d398539'),
51+
kind: Cardano.NativeScriptKind.RequireSignature
52+
}
53+
]
54+
};
55+
56+
const stakingScript: Cardano.NativeScript = {
57+
__type: Cardano.ScriptType.Native,
58+
kind: Cardano.NativeScriptKind.RequireAnyOf,
59+
scripts: [
60+
{
61+
__type: Cardano.ScriptType.Native,
62+
keyHash: walletStakeKeyHash,
63+
kind: Cardano.NativeScriptKind.RequireSignature
64+
},
65+
{
66+
__type: Cardano.ScriptType.Native,
67+
keyHash: Crypto.Ed25519KeyHashHex('b275b08c999097247f7c17e77007c7010cd19f20cc086ad99d398539'),
68+
kind: Cardano.NativeScriptKind.RequireSignature
69+
}
70+
]
71+
};
72+
73+
const outputs: Cardano.TxOut[] = [
74+
{
75+
address: Cardano.PaymentAddress(
76+
'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w'
77+
),
78+
scriptReference: paymentScript,
79+
value: { coins: 11_111_111n }
80+
}
81+
];
82+
const props: InitializeTxProps = {
83+
outputs: new Set<Cardano.TxOut>(outputs)
84+
};
85+
86+
wallet = createSharedWallet(
87+
{ name: 'Shared HW Wallet' },
88+
{
89+
assetProvider: mocks.mockAssetProvider(),
90+
chainHistoryProvider: mocks.mockChainHistoryProvider(),
91+
logger,
92+
networkInfoProvider: mocks.mockNetworkInfoProvider(),
93+
paymentScript,
94+
rewardAccountInfoProvider: mocks.mockRewardAccountInfoProvider(),
95+
rewardsProvider: mocks.mockRewardsProvider(),
96+
stakingScript,
97+
txSubmitProvider: mocks.mockTxSubmitProvider(),
98+
utxoProvider: mocks.mockUtxoProvider(),
99+
witnesser: util.createBip32Ed25519Witnesser(util.createAsyncKeyAgent(ledgerKeyAgent))
100+
}
101+
);
102+
txInternals = await wallet.initializeTx(props);
103+
});
104+
105+
afterAll(() => wallet.shutdown());
106+
107+
it('successfully signs a transaction', async () => {
108+
const tx = await wallet.finalizeTx({ tx: txInternals });
109+
expect(tx.witness.signatures.size).toBe(1);
110+
});
111+
});
112+
});

0 commit comments

Comments
 (0)