diff --git a/.changeset/real-crews-switch.md b/.changeset/real-crews-switch.md new file mode 100644 index 000000000..1cf45c42b --- /dev/null +++ b/.changeset/real-crews-switch.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/keyring-eth": minor +--- + +Implement SignTransactionUseCase diff --git a/packages/signer/keyring-eth/jest.config.ts b/packages/signer/keyring-eth/jest.config.ts index 175e4572f..c2216d42a 100644 --- a/packages/signer/keyring-eth/jest.config.ts +++ b/packages/signer/keyring-eth/jest.config.ts @@ -16,6 +16,7 @@ const config: JestConfigWithTsJest = { "!src/**/*.stub.ts", "!src/index.ts", "!src/api/index.ts", + "!src/**/__test-utils__/*", ], moduleNameMapper: { ...paths, diff --git a/packages/signer/keyring-eth/src/api/KeyringEth.ts b/packages/signer/keyring-eth/src/api/KeyringEth.ts index ad2ea148c..5b12b73c9 100644 --- a/packages/signer/keyring-eth/src/api/KeyringEth.ts +++ b/packages/signer/keyring-eth/src/api/KeyringEth.ts @@ -1,19 +1,19 @@ import { GetAddressDAReturnType } from "@api/app-binder/GetAddressDeviceActionTypes"; import { SignTypedDataDAReturnType } from "@api/app-binder/SignTypedDataDeviceActionTypes"; import { AddressOptions } from "@api/model/AddressOptions"; -import { Signature } from "@api/model/Signature"; import { Transaction } from "@api/model/Transaction"; import { TransactionOptions } from "@api/model/TransactionOptions"; import { TypedData } from "@api/model/TypedData"; import { SignPersonalMessageDAReturnType } from "./app-binder/SignPersonalMessageDeviceActionTypes"; +import { SignTransactionDAReturnType } from "./app-binder/SignTransactionDeviceActionTypes"; export interface KeyringEth { signTransaction: ( derivationPath: string, transaction: Transaction, options?: TransactionOptions, - ) => Promise; + ) => SignTransactionDAReturnType; signMessage: ( derivationPath: string, message: string, diff --git a/packages/signer/keyring-eth/src/api/index.ts b/packages/signer/keyring-eth/src/api/index.ts index 8767f3b5f..64de4700a 100644 --- a/packages/signer/keyring-eth/src/api/index.ts +++ b/packages/signer/keyring-eth/src/api/index.ts @@ -6,6 +6,13 @@ export type { SignPersonalMessageDAOutput, SignPersonalMessageDAState, } from "@api/app-binder/SignPersonalMessageDeviceActionTypes"; +export type { + SignTransactionDAError, + SignTransactionDAInput, + SignTransactionDAIntermediateValue, + SignTransactionDAOutput, + SignTransactionDAState, +} from "@api/app-binder/SignTransactionDeviceActionTypes"; export { type SignTypedDataDAError, type SignTypedDataDAInput, diff --git a/packages/signer/keyring-eth/src/internal/DefaultKeyringEth.ts b/packages/signer/keyring-eth/src/internal/DefaultKeyringEth.ts index dbd8b3f79..fb3dbf5b2 100644 --- a/packages/signer/keyring-eth/src/internal/DefaultKeyringEth.ts +++ b/packages/signer/keyring-eth/src/internal/DefaultKeyringEth.ts @@ -4,10 +4,10 @@ import { Container } from "inversify"; import { GetAddressDAReturnType } from "@api/app-binder/GetAddressDeviceActionTypes"; import { SignPersonalMessageDAReturnType } from "@api/app-binder/SignPersonalMessageDeviceActionTypes"; +import { SignTransactionDAReturnType } from "@api/app-binder/SignTransactionDeviceActionTypes"; import { SignTypedDataDAReturnType } from "@api/app-binder/SignTypedDataDeviceActionTypes"; import { KeyringEth } from "@api/KeyringEth"; import { AddressOptions } from "@api/model/AddressOptions"; -import { Signature } from "@api/model/Signature"; import { Transaction } from "@api/model/Transaction"; import { TransactionOptions } from "@api/model/TransactionOptions"; import { TypedData } from "@api/model/TypedData"; @@ -38,11 +38,11 @@ export class DefaultKeyringEth implements KeyringEth { this._container = makeContainer({ sdk, sessionId, contextModule }); } - async signTransaction( + signTransaction( derivationPath: string, transaction: Transaction, options?: TransactionOptions, - ): Promise { + ): SignTransactionDAReturnType { return this._container .get(transactionTypes.SignTransactionUseCase) .execute(derivationPath, transaction, options); diff --git a/packages/signer/keyring-eth/src/internal/app-binder/EthAppBinder.test.ts b/packages/signer/keyring-eth/src/internal/app-binder/EthAppBinder.test.ts index c224a3e85..76b9e7f3d 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/EthAppBinder.test.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/EthAppBinder.test.ts @@ -3,6 +3,7 @@ import { DeviceActionState, DeviceSdk } from "@ledgerhq/device-sdk-core"; import { DeviceActionStatus } from "@ledgerhq/device-sdk-core"; import { SendCommandInAppDeviceAction } from "@ledgerhq/device-sdk-core"; import { UserInteractionRequired } from "@ledgerhq/device-sdk-core"; +import { Transaction } from "ethers-v6"; import { from } from "rxjs"; import { @@ -10,6 +11,16 @@ import { GetAddressDAIntermediateValue, GetAddressDAOutput, } from "@api/app-binder/GetAddressDeviceActionTypes"; +import { + SignPersonalMessageDAError, + SignPersonalMessageDAIntermediateValue, + SignPersonalMessageDAOutput, +} from "@api/app-binder/SignPersonalMessageDeviceActionTypes"; +import { + SignTransactionDAError, + SignTransactionDAIntermediateValue, + SignTransactionDAOutput, +} from "@api/app-binder/SignTransactionDeviceActionTypes"; import { SignTypedDataDAError, SignTypedDataDAIntermediateValue, @@ -17,6 +28,7 @@ import { } from "@api/app-binder/SignTypedDataDeviceActionTypes"; import { type Signature } from "@api/model/Signature"; import { type TypedData } from "@api/model/TypedData"; +import { TransactionMapperService } from "@internal/transaction/service/mapper/TransactionMapperService"; import { type TypedDataParserService } from "@internal/typed-data/service/TypedDataParserService"; import { GetAddressCommand } from "./command/GetAddressCommand"; @@ -31,6 +43,9 @@ describe("EthAppBinder", () => { getContexts: jest.fn(), getTypedDataFilters: jest.fn(), }; + const mockedMapper: TransactionMapperService = { + mapTransactionToSubset: jest.fn(), + } as unknown as TransactionMapperService; beforeEach(() => { jest.clearAllMocks(); @@ -61,6 +76,7 @@ describe("EthAppBinder", () => { const appBinder = new EthAppBinder( mockedSdk, mockedContextModule, + mockedMapper, "sessionId", ); const { observable } = appBinder.getAddress({ @@ -114,6 +130,7 @@ describe("EthAppBinder", () => { const appBinder = new EthAppBinder( mockedSdk, mockedContextModule, + mockedMapper, "sessionId", ); appBinder.getAddress(params); @@ -143,6 +160,7 @@ describe("EthAppBinder", () => { const appBinder = new EthAppBinder( mockedSdk, mockedContextModule, + mockedMapper, "sessionId", ); appBinder.getAddress(params); @@ -162,6 +180,209 @@ describe("EthAppBinder", () => { }); }); + describe("signTransaction", () => { + it("should return the signature", (done) => { + // GIVEN + const signature: Signature = { + r: `0xDEAD`, + s: `0xBEEF`, + v: 0, + }; + const transaction: Transaction = new Transaction(); + transaction.to = "0x1234567890123456789012345678901234567890"; + transaction.value = 0n; + const options = {}; + + jest.spyOn(mockedSdk, "executeDeviceAction").mockReturnValue({ + observable: from([ + { + status: DeviceActionStatus.Completed, + output: signature, + } as DeviceActionState< + SignTypedDataDAOutput, + SignTypedDataDAError, + SignTypedDataDAIntermediateValue + >, + ]), + cancel: jest.fn(), + }); + + // WHEN + const appBinder = new EthAppBinder( + mockedSdk, + mockedContextModule, + mockedMapper, + "sessionId", + ); + const { observable } = appBinder.signTransaction({ + derivationPath: "44'/60'/3'/2/1", + transaction, + options, + }); + + // THEN + const states: DeviceActionState< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >[] = []; + observable.subscribe({ + next: (state) => { + states.push(state); + }, + error: (err) => { + done(err); + }, + complete: () => { + try { + expect(states).toEqual([ + { + status: DeviceActionStatus.Completed, + output: signature, + }, + ]); + done(); + } catch (err) { + done(err); + } + }, + }); + }); + + it("should return the signature without options", (done) => { + // GIVEN + const signature: Signature = { + r: `0xDEAD`, + s: `0xBEEF`, + v: 0, + }; + const transaction: Transaction = new Transaction(); + transaction.to = "0x1234567890123456789012345678901234567890"; + transaction.value = 0n; + + jest.spyOn(mockedSdk, "executeDeviceAction").mockReturnValue({ + observable: from([ + { + status: DeviceActionStatus.Completed, + output: signature, + } as DeviceActionState< + SignTypedDataDAOutput, + SignTypedDataDAError, + SignTypedDataDAIntermediateValue + >, + ]), + cancel: jest.fn(), + }); + + // WHEN + const appBinder = new EthAppBinder( + mockedSdk, + mockedContextModule, + mockedMapper, + "sessionId", + ); + const { observable } = appBinder.signTransaction({ + derivationPath: "44'/60'/3'/2/1", + transaction, + options: undefined, + }); + + // THEN + const states: DeviceActionState< + SignTransactionDAOutput, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >[] = []; + observable.subscribe({ + next: (state) => { + states.push(state); + }, + error: (err) => { + done(err); + }, + complete: () => { + try { + expect(states).toEqual([ + { + status: DeviceActionStatus.Completed, + output: signature, + }, + ]); + done(); + } catch (err) { + done(err); + } + }, + }); + }); + }); + + describe("signMessage", () => { + it("should return the signature", (done) => { + // GIVEN + const signature: Signature = { + r: `0xDEAD`, + s: `0xBEEF`, + v: 0, + }; + const message = "Hello, World!"; + + jest.spyOn(mockedSdk, "executeDeviceAction").mockReturnValue({ + observable: from([ + { + status: DeviceActionStatus.Completed, + output: signature, + } as DeviceActionState< + SignPersonalMessageDAOutput, + SignPersonalMessageDAError, + SignPersonalMessageDAIntermediateValue + >, + ]), + cancel: jest.fn(), + }); + + // WHEN + const appBinder = new EthAppBinder( + mockedSdk, + mockedContextModule, + mockedMapper, + "sessionId", + ); + const { observable } = appBinder.signPersonalMessage({ + derivationPath: "44'/60'/3'/2/1", + message, + }); + + // THEN + const states: DeviceActionState< + SignPersonalMessageDAOutput, + SignPersonalMessageDAError, + SignPersonalMessageDAIntermediateValue + >[] = []; + observable.subscribe({ + next: (state) => { + states.push(state); + }, + error: (err) => { + done(err); + }, + complete: () => { + try { + expect(states).toEqual([ + { + status: DeviceActionStatus.Completed, + output: signature, + }, + ]); + done(); + } catch (err) { + done(err); + } + }, + }); + }); + }); + describe("signTypedData", () => { it("should return the signature", (done) => { // GIVEN @@ -198,6 +419,7 @@ describe("EthAppBinder", () => { const appBinder = new EthAppBinder( mockedSdk, mockedContextModule, + mockedMapper, "sessionId", ); const { observable } = appBinder.signTypedData({ diff --git a/packages/signer/keyring-eth/src/internal/app-binder/EthAppBinder.ts b/packages/signer/keyring-eth/src/internal/app-binder/EthAppBinder.ts index 287041d2a..58f364e26 100644 --- a/packages/signer/keyring-eth/src/internal/app-binder/EthAppBinder.ts +++ b/packages/signer/keyring-eth/src/internal/app-binder/EthAppBinder.ts @@ -4,40 +4,40 @@ import { SendCommandInAppDeviceAction } from "@ledgerhq/device-sdk-core"; import { UserInteractionRequired } from "@ledgerhq/device-sdk-core"; import { inject, injectable } from "inversify"; -import { GetAddressDAReturnType } from "@api/app-binder/GetAddressDeviceActionTypes"; -import { SignPersonalMessageDAReturnType } from "@api/app-binder/SignPersonalMessageDeviceActionTypes"; -import { SignTypedDataDAReturnType } from "@api/app-binder/SignTypedDataDeviceActionTypes"; -import { TypedData } from "@api/model/TypedData"; +import { type GetAddressDAReturnType } from "@api/app-binder/GetAddressDeviceActionTypes"; +import { type SignPersonalMessageDAReturnType } from "@api/app-binder/SignPersonalMessageDeviceActionTypes"; +import { type SignTransactionDAReturnType } from "@api/app-binder/SignTransactionDeviceActionTypes"; +import { type SignTypedDataDAReturnType } from "@api/app-binder/SignTypedDataDeviceActionTypes"; +import { type Transaction } from "@api/model/Transaction"; +import { type TransactionOptions } from "@api/model/TransactionOptions"; +import { type TypedData } from "@api/model/TypedData"; import { SignTypedDataDeviceAction } from "@internal/app-binder/device-action/SignTypedData/SignTypedDataDeviceAction"; import { externalTypes } from "@internal/externalTypes"; -import { TypedDataParserService } from "@internal/typed-data/service/TypedDataParserService"; +import { transactionTypes } from "@internal/transaction/di/transactionTypes"; +import { TransactionMapperService } from "@internal/transaction/service/mapper/TransactionMapperService"; +import { type TypedDataParserService } from "@internal/typed-data/service/TypedDataParserService"; import { GetAddressCommand } from "./command/GetAddressCommand"; import { SignPersonalMessageDeviceAction } from "./device-action/SignPersonalMessage/SignPersonalMessageDeviceAction"; +import { SignTransactionDeviceAction } from "./device-action/SignTransaction/SignTransactionDeviceAction"; @injectable() export class EthAppBinder { - private _sdk: DeviceSdk; - private _contextModule: ContextModule; - private _sessionId: DeviceSessionId; - constructor( - @inject(externalTypes.Sdk) sdk: DeviceSdk, - @inject(externalTypes.ContextModule) contextModule: ContextModule, - @inject(externalTypes.SessionId) sessionId: DeviceSessionId, - ) { - this._sdk = sdk; - this._contextModule = contextModule; - this._sessionId = sessionId; - } + @inject(externalTypes.Sdk) private sdk: DeviceSdk, + @inject(externalTypes.ContextModule) private contextModule: ContextModule, + @inject(transactionTypes.TransactionMapperService) + private mapper: TransactionMapperService, + @inject(externalTypes.SessionId) private sessionId: DeviceSessionId, + ) {} getAddress(args: { derivationPath: string; checkOnDevice?: boolean; returnChainCode?: boolean; }): GetAddressDAReturnType { - return this._sdk.executeDeviceAction({ - sessionId: this._sessionId, + return this.sdk.executeDeviceAction({ + sessionId: this.sessionId, deviceAction: new SendCommandInAppDeviceAction({ input: { command: new GetAddressCommand(args), @@ -54,8 +54,8 @@ export class EthAppBinder { derivationPath: string; message: string; }): SignPersonalMessageDAReturnType { - return this._sdk.executeDeviceAction({ - sessionId: this._sessionId, + return this.sdk.executeDeviceAction({ + sessionId: this.sessionId, deviceAction: new SignPersonalMessageDeviceAction({ input: { derivationPath: args.derivationPath, @@ -65,19 +65,38 @@ export class EthAppBinder { }); } + signTransaction(args: { + derivationPath: string; + transaction: Transaction; + options?: TransactionOptions; + }): SignTransactionDAReturnType { + return this.sdk.executeDeviceAction({ + sessionId: this.sessionId, + deviceAction: new SignTransactionDeviceAction({ + input: { + derivationPath: args.derivationPath, + transaction: args.transaction, + mapper: this.mapper, + contextModule: this.contextModule, + options: args.options ?? {}, + }, + }), + }); + } + signTypedData(args: { derivationPath: string; parser: TypedDataParserService; data: TypedData; }): SignTypedDataDAReturnType { - return this._sdk.executeDeviceAction({ - sessionId: this._sessionId, + return this.sdk.executeDeviceAction({ + sessionId: this.sessionId, deviceAction: new SignTypedDataDeviceAction({ input: { derivationPath: args.derivationPath, data: args.data, parser: args.parser, - contextModule: this._contextModule, + contextModule: this.contextModule, }, }), }); diff --git a/packages/signer/keyring-eth/src/internal/transaction/use-case/SignTransactionUseCase.test.ts b/packages/signer/keyring-eth/src/internal/transaction/use-case/SignTransactionUseCase.test.ts new file mode 100644 index 000000000..59b794f1b --- /dev/null +++ b/packages/signer/keyring-eth/src/internal/transaction/use-case/SignTransactionUseCase.test.ts @@ -0,0 +1,29 @@ +import { Transaction } from "ethers-v6"; + +import { EthAppBinder } from "@internal/app-binder/EthAppBinder"; + +import { SignTransactionUseCase } from "./SignTransactionUseCase"; + +describe("SignTransactionUseCase", () => { + it("should call signTransaction on appBinder with the correct arguments", () => { + // Given + const derivationPath = "m/44'/60'/0'/0/0"; + const transaction: Transaction = new Transaction(); + transaction.to = "0x1234567890123456789012345678901234567890"; + transaction.value = 0n; + transaction.data = "0x"; + const appBinder: EthAppBinder = { + signTransaction: jest.fn(), + } as unknown as EthAppBinder; + const useCase = new SignTransactionUseCase(appBinder); + + // When + useCase.execute(derivationPath, transaction); + + // Then + expect(appBinder.signTransaction).toHaveBeenCalledWith({ + derivationPath, + transaction, + }); + }); +}); diff --git a/packages/signer/keyring-eth/src/internal/transaction/use-case/SignTransactionUseCase.ts b/packages/signer/keyring-eth/src/internal/transaction/use-case/SignTransactionUseCase.ts index f954269d5..cfd8d2ba0 100644 --- a/packages/signer/keyring-eth/src/internal/transaction/use-case/SignTransactionUseCase.ts +++ b/packages/signer/keyring-eth/src/internal/transaction/use-case/SignTransactionUseCase.ts @@ -1,56 +1,31 @@ -import type { ContextModule } from "@ledgerhq/context-module"; import { inject, injectable } from "inversify"; -import { Signature } from "@api/model/Signature"; +import { SignTransactionDAReturnType } from "@api/app-binder/SignTransactionDeviceActionTypes"; import { Transaction } from "@api/model/Transaction"; import { TransactionOptions } from "@api/model/TransactionOptions"; import { appBinderTypes } from "@internal/app-binder/di/appBinderTypes"; import { EthAppBinder } from "@internal/app-binder/EthAppBinder"; -import { externalTypes } from "@internal/externalTypes"; -import { transactionTypes } from "@internal/transaction/di/transactionTypes"; -import { TransactionMapperService } from "@internal/transaction/service/mapper/TransactionMapperService"; @injectable() export class SignTransactionUseCase { - // @ts-expect-error temporary ignore - private _contextModule: ContextModule; - // @ts-expect-error temporary ignore private _appBinding: EthAppBinder; - private _mapper: TransactionMapperService; constructor( @inject(appBinderTypes.AppBinding) appBinding: EthAppBinder, - @inject(externalTypes.ContextModule) - contextModule: ContextModule, - @inject(transactionTypes.TransactionMapperService) - transactionMapperService: TransactionMapperService, ) { - this._contextModule = contextModule; this._appBinding = appBinding; - this._mapper = transactionMapperService; } - async execute( - _derivationPath: string, + execute( + derivationPath: string, transaction: Transaction, - _options?: TransactionOptions, - ): Promise { - // TODO: 1- map the transaction to TransactionSubset from a Mapper module - // 2- fetch the challenge from the app binder - // 3- fetch ClearSignContext[] from the context module - // 4- call app binding provides for each ClearSignContext - // 5- then sign the transaction and return the signature - - // @ts-expect-error temporary ignore - const _subset = this._mapper.mapTransactionToSubset(transaction); - - /** - _subset; - this._contextModule; - this._appBinding; - */ - - return Promise.resolve({} as Signature); + options?: TransactionOptions, + ): SignTransactionDAReturnType { + return this._appBinding.signTransaction({ + derivationPath, + transaction, + options, + }); } }