Skip to content

feat: sign multi-sig transaction with hw wallet #1604

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/Cardano/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './resolveInputValue';
export * from './phase2Validation';
export * from './addressesShareAnyKey';
export * from './plutusDataUtils';
export * from './isScriptAddress';
8 changes: 8 additions & 0 deletions packages/core/src/Cardano/util/isScriptAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Address, CredentialType, PaymentAddress } from '../Address';

export const isScriptAddress = (address: PaymentAddress): boolean => {
const baseAddress = Address.fromBech32(address).asBase();
const paymentCredential = baseAddress?.getPaymentCredential();
const stakeCredential = baseAddress?.getStakeCredential();
return paymentCredential?.type === CredentialType.ScriptHash && stakeCredential?.type === CredentialType.ScriptHash;
};
18 changes: 18 additions & 0 deletions packages/core/test/Cardano/util/isScriptAddress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { PaymentAddress } from '../../../src/Cardano';
import { isScriptAddress } from '../../../src/Cardano/util';

describe('isScriptAddress', () => {
it('returns false when it receives a non-script address', () => {
const nonScriptAddress = PaymentAddress(
'addr_test1qpfhhfy2qgls50r9u4yh0l7z67xpg0a5rrhkmvzcuqrd0znuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q9gw0lz'
);
expect(isScriptAddress(nonScriptAddress)).toBe(false);
});

it('returns true when it receives a script address', () => {
const scriptAddress = PaymentAddress(
'addr_test1xr806j8xcq6cw6jjkzfxyewyue33zwnu4ajnu28hakp5fmc6gddlgeqee97vwdeafwrdgrtzp2rw8rlchjf25ld7r2ssptq3m9'
);
expect(isScriptAddress(scriptAddress)).toBe(true);
});
});
9 changes: 8 additions & 1 deletion packages/hardware-ledger/src/LedgerKeyAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/hardware-ledger/src/transformers/txOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ const toDestination: Transform<Cardano.TxOut, Ledger.TxOutputDestination, Ledger
context
) => {
const knownAddress = context?.knownAddresses.find((address) => address.address === txOut.address);
const isScriptAddress = Cardano.util.isScriptAddress(txOut.address);

if (knownAddress) {
if (knownAddress && !isScriptAddress) {
const paymentKeyPath = util.paymentKeyPathFromGroupedAddress(knownAddress);
const stakeKeyPath = util.stakeKeyPathFromGroupedAddress(knownAddress);

Expand Down
33 changes: 30 additions & 3 deletions packages/hardware-trezor/src/TrezorKeyAgent.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +10,7 @@ import {
KeyAgentDependencies,
KeyAgentType,
KeyPurpose,
KeyRole,
SerializableTrezorKeyAgentData,
SignBlobResult,
SignTransactionContext,
Expand Down Expand Up @@ -68,6 +70,10 @@ const containsOnlyScriptHashCredentials = (tx: Omit<Trezor.CardanoSignTransactio
return !tx.withdrawals?.some((withdrawal) => !withdrawal.scriptHash);
};

const multiSigWitnessPaths: BIP32Path[] = [
util.accountKeyDerivationPathToBip32Path(0, { index: 0, role: KeyRole.External }, KeyPurpose.MULTI_SIG)
];

const isMultiSig = (tx: Omit<Trezor.CardanoSignTransaction, 'signingMode'>): 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
Expand Down Expand Up @@ -100,7 +106,8 @@ export class TrezorKeyAgent extends KeyAgentBase {
manifest,
communicationType,
silentMode = false,
lazyLoad = false
lazyLoad = false,
shouldHandlePassphrase = false
}: TrezorConfig): Promise<boolean> {
const trezorConnect = getTrezorConnect(communicationType);
try {
Expand All @@ -116,6 +123,23 @@ export class TrezorKeyAgent extends KeyAgentBase {
// Show Trezor Suite popup. Disabled for node based apps
popup: communicationType !== CommunicationType.Node && !silentMode
});

if (shouldHandlePassphrase) {
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;
Expand Down Expand Up @@ -215,7 +239,7 @@ export class TrezorKeyAgent extends KeyAgentBase {

async signTransaction(
txBody: Serialization.TransactionBody,
{ knownAddresses, txInKeyPathMap }: SignTransactionContext
{ knownAddresses, txInKeyPathMap, scripts }: SignTransactionContext
): Promise<Cardano.Signatures> {
try {
await this.isTrezorInitialized;
Expand All @@ -235,12 +259,15 @@ 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)
.ownSignatureKeyPaths(body, knownAddresses, txInKeyPathMap, undefined, scripts)
.map((derivationPath) => this.derivePublicKey(derivationPath))
);

Expand Down
3 changes: 2 additions & 1 deletion packages/hardware-trezor/src/transformers/txOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ const toDestination: Transform<Cardano.TxOut, TrezorTxOutputDestination, TrezorT
context
) => {
const knownAddress = context?.knownAddresses.find((address: GroupedAddress) => address.address === txOut.address);
const isScriptAddress = Cardano.util.isScriptAddress(txOut.address);

if (!knownAddress) {
if (!knownAddress || isScriptAddress) {
return {
address: txOut.address
};
Expand Down
2 changes: 2 additions & 0 deletions packages/key-management/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export interface TrezorConfig {
email: string;
appUrl: string;
};
/** When set to true, Trezor automatically handle passphrase entry by forcing it to occur on the device */
shouldHandlePassphrase?: boolean;
}

export interface SerializableKeyAgentDataBase {
Expand Down
25 changes: 17 additions & 8 deletions packages/key-management/src/util/ownSignatureKeyPaths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Crypto from '@cardano-sdk/crypto';
import { AccountKeyDerivationPath, GroupedAddress, TxInId, TxInKeyPathMap } from '../types';
import { AccountKeyDerivationPath, GroupedAddress, KeyRole, TxInId, TxInKeyPathMap } from '../types';
import { Cardano } from '@cardano-sdk/core';
import { DREP_KEY_DERIVATION_PATH } from './key';
import { Ed25519KeyHashHex } from '@cardano-sdk/crypto';
Expand Down Expand Up @@ -300,15 +300,24 @@ const checkStakeCredential = (address: GroupedAddress, keyHash: Crypto.Ed25519Ke
? { derivationPaths: [address.stakeKeyDerivationPath], requiresForeignSignatures: false }
: { derivationPaths: [], requiresForeignSignatures: true };

const checkPaymentCredential = (address: GroupedAddress, keyHash: Crypto.Ed25519KeyHashHex): SignatureCheck => {
const checkPaymentCredential = (address: GroupedAddress, keyHash: Crypto.Ed25519KeyHashHex) => {
const paymentCredential = Cardano.Address.fromBech32(address.address)?.asBase()?.getPaymentCredential();
return paymentCredential?.type === Cardano.CredentialType.KeyHash &&
if (
paymentCredential?.type === Cardano.CredentialType.ScriptHash &&
paymentCredential.hash === Crypto.Hash28ByteBase16.fromEd25519KeyHashHex(keyHash)
? {
derivationPaths: [{ index: address.index, role: Number(address.type) }],
requiresForeignSignatures: false
}
: { derivationPaths: [], requiresForeignSignatures: true };
)
return {
derivationPaths: [{ index: address.index, role: Number(address.type) }],
requiresForeignSignatures: false
};

if (paymentCredential?.type === Cardano.CredentialType.ScriptHash) {
return {
derivationPaths: [{ index: address.index, role: KeyRole.External }],
requiresForeignSignatures: false
};
}
return { derivationPaths: [], requiresForeignSignatures: true };
};

const combineSignatureChecks = (a: SignatureCheck, b: SignatureCheck): SignatureCheck => ({
Expand Down
42 changes: 41 additions & 1 deletion packages/key-management/test/util/ownSignaturePaths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const createGroupedAddress = (
rewardAccount: Cardano.RewardAccount,
type: AddressType,
index: number,
stakeKeyDerivationPath: AccountKeyDerivationPath
stakeKeyDerivationPath?: AccountKeyDerivationPath
) =>
({
...createBaseGroupedAddress(address, rewardAccount, type, index),
Expand Down Expand Up @@ -633,5 +633,45 @@ describe('KeyManagement.util.ownSignaturePaths', () => {

expect(util.ownSignatureKeyPaths(txBody, [knownAddress1], {}, undefined, scripts)).toEqual([]);
});
it('includes derivation paths for multi-signature native scripts', async () => {
const scriptAddress = Cardano.PaymentAddress(
'addr_test1xr806j8xcq6cw6jjkzfxyewyue33zwnu4ajnu28hakp5fmc6gddlgeqee97vwdeafwrdgrtzp2rw8rlchjf25ld7r2ssptq3m9'
);
const scriptRewardAccount = Cardano.RewardAccount(
'stake_test17qdyxkl5vsvujlx8xu75hpk5p43q4phr3lutey420klp4gg7zmhrn'
);
const txBody: Cardano.TxBody = {
fee: BigInt(0),
inputs: [{}, {}, {}] as Cardano.TxIn[],
outputs: []
};

const scripts: Cardano.Script[] = [
{
__type: Cardano.ScriptType.Native,
kind: Cardano.NativeScriptKind.RequireAnyOf,
scripts: [
{
__type: Cardano.ScriptType.Native,
keyHash: Ed25519KeyHashHex('b498c0eaceb9a8c7c829d36fc84e892113c9d2636b53b0636d7518b4'),
kind: Cardano.NativeScriptKind.RequireSignature
},
{
__type: Cardano.ScriptType.Native,
keyHash: Ed25519KeyHashHex(otherStakeKeyHash),
kind: Cardano.NativeScriptKind.RequireSignature
}
]
}
];

const knownAddress = createGroupedAddress(scriptAddress, scriptRewardAccount, AddressType.External, 0);
expect(util.ownSignatureKeyPaths(txBody, [knownAddress], {}, undefined, scripts)).toEqual([
{
index: 0,
role: KeyRole.External
}
]);
});
});
});
Original file line number Diff line number Diff line change
@@ -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<Cardano.TxOut>(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);
});
});
});
Loading
Loading