Skip to content

Commit 5f60e89

Browse files
authored
Merge pull request #1666 from input-output-hk/feat/web-extension-derivation-type
feat(web-extension): support wallet-specific trezorConfig in SigningCoordinator
2 parents 146426f + 34d9d35 commit 5f60e89

File tree

3 files changed

+197
-69
lines changed

3 files changed

+197
-69
lines changed

packages/web-extension/src/walletManager/SigningCoordinator/SigningCoordinator.ts

Lines changed: 120 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import { AnyBip32Wallet, Bip32WalletAccount, InMemoryWallet, WalletType } from '../types';
12
import { Cardano, Serialization } from '@cardano-sdk/core';
23
import { Cip30DataSignature } from '@cardano-sdk/dapp-connector';
34
import { CustomError } from 'ts-custom-error';
4-
import { InMemoryWallet, WalletType } from '../types';
55
import { KeyAgent, KeyPurpose, SignDataContext, TrezorConfig, errors } from '@cardano-sdk/key-management';
66
import { KeyAgentFactory } from './KeyAgentFactory';
77
import { Logger } from 'ts-log';
@@ -83,6 +83,24 @@ export class SigningCoordinator<WalletMetadata extends {}, AccountMetadata exten
8383
this.#logger = contextLogger(logger, 'SigningCoordinator');
8484
}
8585

86+
/**
87+
* Gets the appropriate TrezorConfig for the given wallet.
88+
*
89+
* This allows wallets to specify only the properties they want to override
90+
* (e.g., derivationType) while inheriting global settings (e.g., communicationType, manifest)
91+
*/
92+
#getTrezorConfig(wallet: AnyBip32Wallet<WalletMetadata, AccountMetadata>): TrezorConfig {
93+
const trezorConfig =
94+
wallet.type === WalletType.Trezor && 'trezorConfig' in wallet.metadata
95+
? (wallet.metadata as { trezorConfig?: Partial<TrezorConfig> }).trezorConfig
96+
: undefined;
97+
98+
return {
99+
...this.#hwOptions, // Global defaults (communicationType, manifest, etc.)
100+
...(trezorConfig || {}) // Wallet-specific overrides (derivationType, etc.)
101+
};
102+
}
103+
86104
async signTransaction(
87105
{ tx, signContext, options }: SignTransactionProps,
88106
requestContext: RequestContext<WalletMetadata, AccountMetadata>
@@ -127,11 +145,8 @@ export class SigningCoordinator<WalletMetadata extends {}, AccountMetadata exten
127145
if (!emitter$.observed) {
128146
return reject(new WrongTargetError('Not expecting sign requests at this time'));
129147
}
130-
const account = request.requestContext.wallet.accounts.find(
131-
({ accountIndex, purpose = KeyPurpose.STANDARD }) =>
132-
accountIndex === request.requestContext.accountIndex && request.requestContext.purpose === purpose
133-
);
134148

149+
const account = this.#findAccount(request);
135150
if (!account) {
136151
return reject(
137152
new errors.ProofGenerationError(
@@ -144,67 +159,107 @@ export class SigningCoordinator<WalletMetadata extends {}, AccountMetadata exten
144159
...request,
145160
reject: async (reason: string) => reject(new errors.AuthenticationError(reason))
146161
};
147-
emitter$.next(
162+
163+
const signRequest =
148164
request.walletType === WalletType.InMemory
149-
? ({
150-
...commonRequestProps,
151-
sign: async (passphrase: Uint8Array, options?: SignOptions) =>
152-
bubbleResolveReject(
153-
async () => {
154-
const wallet = request.requestContext.wallet as InMemoryWallet<WalletMetadata, AccountMetadata>;
155-
try {
156-
const result = await sign(
157-
await this.#keyAgentFactory.InMemory({
158-
accountIndex: account.accountIndex,
159-
chainId: request.requestContext.chainId,
160-
encryptedRootPrivateKeyBytes: [
161-
...Buffer.from(wallet.encryptedSecrets.rootPrivateKeyBytes, 'hex')
162-
],
163-
extendedAccountPublicKey: account.extendedAccountPublicKey,
164-
getPassphrase: async () => passphrase,
165-
purpose: account.purpose || KeyPurpose.STANDARD
166-
})
167-
);
168-
clearPassphrase(passphrase);
169-
return result;
170-
} catch (error) {
171-
clearPassphrase(passphrase);
172-
return throwMaybeWrappedWithNoRejectError(error, options);
173-
}
174-
},
175-
resolve,
176-
reject
177-
),
178-
walletType: request.walletType
179-
} as Req)
180-
: ({
181-
...commonRequestProps,
182-
sign: async (): Promise<R> =>
183-
bubbleResolveReject(
184-
async (options?: SignOptions) =>
185-
sign(
186-
request.walletType === WalletType.Ledger
187-
? await this.#keyAgentFactory.Ledger({
188-
accountIndex: request.requestContext.accountIndex,
189-
chainId: request.requestContext.chainId,
190-
communicationType: this.#hwOptions.communicationType,
191-
extendedAccountPublicKey: account.extendedAccountPublicKey,
192-
purpose: account.purpose || KeyPurpose.STANDARD
193-
})
194-
: await this.#keyAgentFactory.Trezor({
195-
accountIndex: request.requestContext.accountIndex,
196-
chainId: request.requestContext.chainId,
197-
extendedAccountPublicKey: account.extendedAccountPublicKey,
198-
purpose: account.purpose || KeyPurpose.STANDARD,
199-
trezorConfig: this.#hwOptions
200-
})
201-
).catch((error) => throwMaybeWrappedWithNoRejectError(error, options)),
202-
resolve,
203-
reject
204-
),
205-
walletType: request.walletType
206-
} as Req)
207-
);
165+
? this.#createInMemorySignRequest(commonRequestProps, account, sign, resolve, reject)
166+
: this.#createHardwareSignRequest(commonRequestProps, account, sign, resolve, reject);
167+
168+
emitter$.next(signRequest);
169+
});
170+
}
171+
172+
#findAccount(request: { requestContext: RequestContext<WalletMetadata, AccountMetadata> }) {
173+
return request.requestContext.wallet.accounts.find(
174+
({ accountIndex, purpose = KeyPurpose.STANDARD }) =>
175+
accountIndex === request.requestContext.accountIndex && request.requestContext.purpose === purpose
176+
);
177+
}
178+
179+
#createInMemorySignRequest<R, Req extends RequestBase<WalletMetadata, AccountMetadata> & SignRequest<R>>(
180+
commonRequestProps: Omit<Req, 'reject' | 'sign'>,
181+
account: Bip32WalletAccount<AccountMetadata>,
182+
sign: (keyAgent: KeyAgent) => Promise<R>,
183+
resolve: (result: R | Promise<R>) => void,
184+
reject: (error: unknown) => void
185+
): Req {
186+
return {
187+
...commonRequestProps,
188+
sign: async (passphrase: Uint8Array, options?: SignOptions) =>
189+
bubbleResolveReject(
190+
async () => {
191+
const wallet = commonRequestProps.requestContext.wallet as InMemoryWallet<WalletMetadata, AccountMetadata>;
192+
try {
193+
const result = await sign(
194+
await this.#keyAgentFactory.InMemory({
195+
accountIndex: account.accountIndex,
196+
chainId: commonRequestProps.requestContext.chainId,
197+
encryptedRootPrivateKeyBytes: [...Buffer.from(wallet.encryptedSecrets.rootPrivateKeyBytes, 'hex')],
198+
extendedAccountPublicKey: account.extendedAccountPublicKey,
199+
getPassphrase: async () => passphrase,
200+
purpose: account.purpose || KeyPurpose.STANDARD
201+
})
202+
);
203+
clearPassphrase(passphrase);
204+
return result;
205+
} catch (error) {
206+
clearPassphrase(passphrase);
207+
return throwMaybeWrappedWithNoRejectError(error, options);
208+
}
209+
},
210+
resolve,
211+
reject
212+
),
213+
walletType: commonRequestProps.walletType
214+
} as Req;
215+
}
216+
217+
#createHardwareSignRequest<R, Req extends RequestBase<WalletMetadata, AccountMetadata> & SignRequest<R>>(
218+
commonRequestProps: Omit<Req, 'reject' | 'sign'>,
219+
account: Bip32WalletAccount<AccountMetadata>,
220+
sign: (keyAgent: KeyAgent) => Promise<R>,
221+
resolve: (result: R | Promise<R>) => void,
222+
reject: (error: unknown) => void
223+
): Req {
224+
return {
225+
...commonRequestProps,
226+
sign: async (): Promise<R> =>
227+
bubbleResolveReject(
228+
async (options?: SignOptions) => {
229+
try {
230+
const keyAgent = await this.#createHardwareKeyAgent(commonRequestProps, account);
231+
return await sign(keyAgent);
232+
} catch (error) {
233+
return throwMaybeWrappedWithNoRejectError(error, options);
234+
}
235+
},
236+
resolve,
237+
reject
238+
),
239+
walletType: commonRequestProps.walletType
240+
} as Req;
241+
}
242+
243+
async #createHardwareKeyAgent(
244+
request: { requestContext: RequestContext<WalletMetadata, AccountMetadata>; walletType: WalletType },
245+
account: Bip32WalletAccount<AccountMetadata>
246+
): Promise<KeyAgent> {
247+
if (request.walletType === WalletType.Ledger) {
248+
return await this.#keyAgentFactory.Ledger({
249+
accountIndex: request.requestContext.accountIndex,
250+
chainId: request.requestContext.chainId,
251+
communicationType: this.#hwOptions.communicationType,
252+
extendedAccountPublicKey: account.extendedAccountPublicKey,
253+
purpose: account.purpose || KeyPurpose.STANDARD
254+
});
255+
}
256+
257+
return await this.#keyAgentFactory.Trezor({
258+
accountIndex: request.requestContext.accountIndex,
259+
chainId: request.requestContext.chainId,
260+
extendedAccountPublicKey: account.extendedAccountPublicKey,
261+
purpose: account.purpose || KeyPurpose.STANDARD,
262+
trezorConfig: this.#getTrezorConfig(request.requestContext.wallet)
208263
});
209264
}
210265
}

packages/web-extension/src/walletManager/types.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AccountKeyDerivationPath, KeyPurpose } from '@cardano-sdk/key-management';
1+
import { AccountKeyDerivationPath, KeyPurpose, TrezorConfig } from '@cardano-sdk/key-management';
22
import { Bip32PublicKeyHex } from '@cardano-sdk/crypto';
33
import { Cardano } from '@cardano-sdk/core';
44
import { HexBlob } from '@cardano-sdk/util';
@@ -29,13 +29,24 @@ export type Bip32Wallet<WalletMetadata extends {}, AccountMetadata extends {}> =
2929
blockchainName?: Blockchain;
3030
};
3131

32-
export type HardwareWallet<WalletMetadata extends {}, AccountMetadata extends {}> = Bip32Wallet<
32+
export type LedgerHardwareWallet<WalletMetadata extends {}, AccountMetadata extends {}> = Bip32Wallet<
3333
WalletMetadata,
3434
AccountMetadata
3535
> & {
36-
type: WalletType.Ledger | WalletType.Trezor;
36+
type: WalletType.Ledger;
3737
};
3838

39+
export type TrezorHardwareWallet<
40+
WalletMetadata extends { trezorConfig?: Partial<TrezorConfig> },
41+
AccountMetadata extends {}
42+
> = Bip32Wallet<WalletMetadata, AccountMetadata> & {
43+
type: WalletType.Trezor;
44+
};
45+
46+
export type HardwareWallet<WalletMetadata extends {}, AccountMetadata extends {}> =
47+
| LedgerHardwareWallet<WalletMetadata, AccountMetadata>
48+
| TrezorHardwareWallet<WalletMetadata, AccountMetadata>;
49+
3950
export type InMemoryWallet<WalletMetadata extends {}, AccountMetadata extends {}> = Bip32Wallet<
4051
WalletMetadata,
4152
AccountMetadata

packages/web-extension/test/walletManager/SigningCoordinator.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,25 @@ import {
44
CommunicationType,
55
InMemoryKeyAgent,
66
KeyPurpose,
7+
MasterKeyGeneration,
78
SignDataContext,
89
SignTransactionContext,
10+
TrezorConfig,
911
cip8,
1012
errors
1113
} from '@cardano-sdk/key-management';
1214
import { Ed25519PublicKeyHex, Ed25519SignatureHex, Hash28ByteBase16 } from '@cardano-sdk/crypto';
13-
import { HexBlob } from '@cardano-sdk/util';
1415
import {
16+
HardwareWallet,
1517
InMemoryWallet,
1618
KeyAgentFactory,
1719
RequestContext,
20+
SignOptions,
1821
SigningCoordinator,
1922
WalletType,
2023
WrongTargetError
2124
} from '../../src';
25+
import { HexBlob } from '@cardano-sdk/util';
2226
import { createAccount } from './util';
2327
import { dummyLogger } from 'ts-log';
2428
import { firstValueFrom } from 'rxjs';
@@ -73,7 +77,9 @@ describe('SigningCoordinator', () => {
7377
signCip8Data: jest.fn(),
7478
signTransaction: jest.fn()
7579
} as unknown as jest.Mocked<InMemoryKeyAgent>;
80+
7681
keyAgentFactory.InMemory.mockResolvedValue(keyAgent);
82+
keyAgentFactory.Trezor.mockResolvedValue(keyAgent as unknown as Awaited<ReturnType<KeyAgentFactory['Trezor']>>);
7783
});
7884

7985
describe('signTransaction', () => {
@@ -171,6 +177,62 @@ describe('SigningCoordinator', () => {
171177
expect(passphrase).toEqual(new Uint8Array([0, 0, 0]));
172178
});
173179
});
180+
181+
it('should pass wallet trezorConfig to Trezor key agent factory', async () => {
182+
const trezorWallet: HardwareWallet<{ trezorConfig?: Partial<TrezorConfig> }, {}> = {
183+
accounts: [createAccount(0, 0)],
184+
metadata: {
185+
trezorConfig: {
186+
communicationType: CommunicationType.Web,
187+
derivationType: 'ICARUS_TREZOR' as MasterKeyGeneration,
188+
manifest: {
189+
appUrl: 'https://custom.app',
190+
191+
}
192+
}
193+
},
194+
type: WalletType.Trezor,
195+
walletId: Hash28ByteBase16('ad63f855e831d937457afc52a21a7f351137e4a9fff26c217817335a')
196+
};
197+
198+
const trezorRequestContext: RequestContext<{}, {}> = {
199+
accountIndex: 0,
200+
chainId: Cardano.ChainIds.Preprod,
201+
purpose: KeyPurpose.STANDARD,
202+
wallet: trezorWallet
203+
};
204+
205+
// Test that the Trezor factory is called with merged config
206+
const reqEmitted = firstValueFrom(signingCoordinator.transactionWitnessRequest$);
207+
void signingCoordinator.signTransaction({ signContext, tx }, trezorRequestContext);
208+
const req = await reqEmitted;
209+
210+
// Verify the request was created
211+
expect(req.walletType).toBe(WalletType.Trezor);
212+
expect(req.requestContext.wallet).toBe(trezorWallet);
213+
214+
// Now call sign() to trigger the key agent factory call
215+
// Cast to hardware wallet request type to access the correct sign method
216+
const hardwareReq = req as { sign(options?: SignOptions): Promise<Cardano.Signatures> };
217+
await hardwareReq.sign({});
218+
219+
// Verify keyAgentFactory.Trezor was called with the correct merged configuration
220+
expect(keyAgentFactory.Trezor).toHaveBeenCalledWith({
221+
accountIndex: 0,
222+
chainId: Cardano.ChainIds.Preprod,
223+
extendedAccountPublicKey: expect.any(String),
224+
purpose: KeyPurpose.STANDARD,
225+
trezorConfig: {
226+
// Wallet-specific overrides take precedence over global defaults
227+
communicationType: CommunicationType.Web, // Wallet override
228+
derivationType: 'ICARUS_TREZOR', // Wallet override
229+
manifest: {
230+
appUrl: 'https://custom.app', // Wallet override
231+
email: '[email protected]' // Wallet override
232+
}
233+
}
234+
});
235+
});
174236
});
175237

176238
describe('signData', () => {

0 commit comments

Comments
 (0)