From 07c575d44361c53d4d6c0e73a1eb61a43d2ed8d4 Mon Sep 17 00:00:00 2001 From: Pierre Aoun Date: Wed, 13 Nov 2024 15:05:05 +0100 Subject: [PATCH] :sparkles: (context-module): Implement generic transaction context loader --- .changeset/eighty-houses-heal.md | 5 + .../src/DefaultContextModule.ts | 5 + packages/signer/context-module/src/di.ts | 2 + .../src/shared/model/ClearSignContext.ts | 21 + .../src/transaction/data/CalldataDto.ts | 167 +++++ .../data/HttpTransactionDataSource.test.ts | 674 ++++++++++++++++++ .../data/HttpTransactionDataSource.ts | 292 ++++++++ .../transaction/data/TransactionDataSource.ts | 16 + .../di/transactionModuleFactory.ts | 13 + .../src/transaction/di/transactionTypes.ts | 4 + .../domain/TransactionContextLoader.test.ts | 135 ++++ .../domain/TransactionContextLoader.ts | 52 ++ .../src/api/SignerEthBuilder.test.ts | 2 +- .../task/ProvideTransactionContextTask.ts | 1 + .../ProvideTransactionGenericContextTask.ts | 7 + 15 files changed, 1395 insertions(+), 1 deletion(-) create mode 100644 .changeset/eighty-houses-heal.md create mode 100644 packages/signer/context-module/src/transaction/data/CalldataDto.ts create mode 100644 packages/signer/context-module/src/transaction/data/HttpTransactionDataSource.test.ts create mode 100644 packages/signer/context-module/src/transaction/data/HttpTransactionDataSource.ts create mode 100644 packages/signer/context-module/src/transaction/data/TransactionDataSource.ts create mode 100644 packages/signer/context-module/src/transaction/di/transactionModuleFactory.ts create mode 100644 packages/signer/context-module/src/transaction/di/transactionTypes.ts create mode 100644 packages/signer/context-module/src/transaction/domain/TransactionContextLoader.test.ts create mode 100644 packages/signer/context-module/src/transaction/domain/TransactionContextLoader.ts diff --git a/.changeset/eighty-houses-heal.md b/.changeset/eighty-houses-heal.md new file mode 100644 index 000000000..eaed314e4 --- /dev/null +++ b/.changeset/eighty-houses-heal.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/context-module": minor +--- + +Implement generic transaction context loader diff --git a/packages/signer/context-module/src/DefaultContextModule.ts b/packages/signer/context-module/src/DefaultContextModule.ts index edef8d583..e8827ab37 100644 --- a/packages/signer/context-module/src/DefaultContextModule.ts +++ b/packages/signer/context-module/src/DefaultContextModule.ts @@ -2,6 +2,7 @@ import { type Container } from "inversify"; import type { TypedDataClearSignContext } from "@/shared/model/TypedDataClearSignContext"; import type { TypedDataContext } from "@/shared/model/TypedDataContext"; +import { transactionTypes } from "@/transaction/di/transactionTypes"; import { type ContextModuleConfig } from "./config/model/ContextModuleConfig"; import { externalPluginTypes } from "./external-plugin/di/externalPluginTypes"; @@ -15,6 +16,7 @@ import { type ClearSignContext } from "./shared/model/ClearSignContext"; import { type TransactionContext } from "./shared/model/TransactionContext"; import { tokenTypes } from "./token/di/tokenTypes"; import { type TokenContextLoader } from "./token/domain/TokenContextLoader"; +import { type TransactionContextLoader } from "./transaction/domain/TransactionContextLoader"; import { typedDataTypes } from "./typed-data/di/typedDataTypes"; import type { TypedDataContextLoader } from "./typed-data/domain/TypedDataContextLoader"; import { type ContextModule } from "./ContextModule"; @@ -43,6 +45,9 @@ export class DefaultContextModule implements ContextModule { ), this._container.get(nftTypes.NftContextLoader), this._container.get(tokenTypes.TokenContextLoader), + this._container.get( + transactionTypes.TransactionContextLoader, + ), ]; } diff --git a/packages/signer/context-module/src/di.ts b/packages/signer/context-module/src/di.ts index 602f989b5..85bb93265 100644 --- a/packages/signer/context-module/src/di.ts +++ b/packages/signer/context-module/src/di.ts @@ -6,6 +6,7 @@ import { externalPluginModuleFactory } from "@/external-plugin/di/externalPlugin import { forwardDomainModuleFactory } from "@/forward-domain/di/forwardDomainModuleFactory"; import { nftModuleFactory } from "@/nft/di/nftModuleFactory"; import { tokenModuleFactory } from "@/token/di/tokenModuleFactory"; +import { transactionModuleFactory } from "@/transaction/di/transactionModuleFactory"; import { typedDataModuleFactory } from "@/typed-data/di/typedDataModuleFactory"; type MakeContainerArgs = { @@ -21,6 +22,7 @@ export const makeContainer = ({ config }: MakeContainerArgs) => { forwardDomainModuleFactory(), nftModuleFactory(), tokenModuleFactory(), + transactionModuleFactory(), typedDataModuleFactory(), ); diff --git a/packages/signer/context-module/src/shared/model/ClearSignContext.ts b/packages/signer/context-module/src/shared/model/ClearSignContext.ts index a8de61af3..8b90c0dea 100644 --- a/packages/signer/context-module/src/shared/model/ClearSignContext.ts +++ b/packages/signer/context-module/src/shared/model/ClearSignContext.ts @@ -1,7 +1,10 @@ +import { type GenericPath } from "./GenericPath"; + export enum ClearSignContextType { TOKEN = "token", NFT = "nft", DOMAIN_NAME = "domainName", + TRUSTED_NAME = "trustedName", PLUGIN = "plugin", EXTERNAL_PLUGIN = "externalPlugin", TRANSACTION_INFO = "transactionInfo", @@ -10,12 +13,30 @@ export enum ClearSignContextType { ERROR = "error", } +export type ClearSignContextReference = + | { + type: ClearSignContextType.TOKEN | ClearSignContextType.NFT; + valuePath: GenericPath; + } + | { + type: ClearSignContextType.TRUSTED_NAME; + valuePath: GenericPath; + types: string[]; + sources: string[]; + }; + export type ClearSignContextSuccess = { type: Exclude; /** * Hexadecimal string representation of the payload. */ payload: string; + /** + * Optional reference to another asset descriptor. + * ie: a 'transactionFieldDescription' descriptor can reference a token or + * a trusted name. + */ + reference?: ClearSignContextReference; }; export type ClearSignContextError = { diff --git a/packages/signer/context-module/src/transaction/data/CalldataDto.ts b/packages/signer/context-module/src/transaction/data/CalldataDto.ts new file mode 100644 index 000000000..1bcc75834 --- /dev/null +++ b/packages/signer/context-module/src/transaction/data/CalldataDto.ts @@ -0,0 +1,167 @@ +export interface CalldataDto { + descriptors_calldata: { + [address: string]: { + [selector: string]: CalldataDescriptor; + }; + }; +} + +export type CalldataDescriptor = CalldataDescriptorV1; // For now only V1 descriptors are supported + +export interface CalldataDescriptorV1 { + type: "calldata"; + version: "v1"; + transaction_info: CalldataTransactionInfoV1; + enums: CalldataEnumV1[]; + fields: CalldataFieldV1[]; +} + +export type CalldataTransactionDescriptor = { + data: string; + signatures: CalldataSignatures; +}; + +export type CalldataSignatures = + | { + prod: string; + test?: string; + } + | { + prod?: string; + test: string; + }; + +export interface CalldataTransactionInfoV1 { + descriptor: CalldataTransactionDescriptor; +} + +export interface CalldataEnumV1 { + descriptor: string; +} + +export interface CalldataFieldV1 { + descriptor: string; + param: CalldataDescriptorParam; +} + +export type CalldataDescriptorParam = + | CalldataDescriptorParamRawV1 + | CalldataDescriptorParamAmountV1 + | CalldataDescriptorParamTokenAmountV1 + | CalldataDescriptorParamNFTV1 + | CalldataDescriptorParamDatetimeV1 + | CalldataDescriptorParamDurationV1 + | CalldataDescriptorParamUnitV1 + | CalldataDescriptorParamEnumV1 + | CalldataDescriptorParamTrustedNameV1; + +export interface CalldataDescriptorParamRawV1 { + type: "RAW"; + value: CalldataDescriptorValueV1; +} + +export interface CalldataDescriptorParamAmountV1 { + type: "AMOUNT"; + value: CalldataDescriptorValueV1; +} + +export interface CalldataDescriptorParamTokenAmountV1 { + type: "TOKEN_AMOUNT"; + value: CalldataDescriptorValueV1; + token?: CalldataDescriptorValueV1; +} + +export interface CalldataDescriptorParamNFTV1 { + type: "NFT"; + value: CalldataDescriptorValueV1; + collection: CalldataDescriptorValueV1; +} + +export interface CalldataDescriptorParamDatetimeV1 { + type: "DATETIME"; + value: CalldataDescriptorValueV1; +} + +export interface CalldataDescriptorParamDurationV1 { + type: "DURATION"; + value: CalldataDescriptorValueV1; +} + +export interface CalldataDescriptorParamUnitV1 { + type: "UNIT"; + value: CalldataDescriptorValueV1; +} + +export interface CalldataDescriptorParamEnumV1 { + type: "ENUM"; + value: CalldataDescriptorValueV1; +} + +export interface CalldataDescriptorParamTrustedNameV1 { + type: "TRUSTED_NAME"; + value: CalldataDescriptorValueV1; + types: string[]; + sources: string[]; +} + +export interface CalldataDescriptorValueV1 { + binary_path: + | CalldataDescriptorContainerPathV1 + | CalldataDescriptorPathElementsV1; + type_family: CalldataDescriptorTypeFamilyV1; + type_size?: number; +} + +export interface CalldataDescriptorPathElementsV1 { + elements: CalldataDescriptorPathElementV1[]; +} + +export type CalldataDescriptorPathElementV1 = + | CalldataDescriptorPathElementTupleV1 + | CalldataDescriptorPathElementArrayV1 + | CalldataDescriptorPathElementRefV1 + | CalldataDescriptorPathElementLeafV1 + | CalldataDescriptorPathElementSliceV1; + +export interface CalldataDescriptorPathElementTupleV1 { + type: "TUPLE"; + offset: number; +} + +export interface CalldataDescriptorPathElementArrayV1 { + type: "ARRAY"; + start?: number; + length?: number; + weight: number; +} + +export interface CalldataDescriptorPathElementRefV1 { + type: "REF"; +} + +export interface CalldataDescriptorPathElementLeafV1 { + type: "LEAF"; + leaf_type: CalldataDescriptorPathLeafTypeV1; +} + +export interface CalldataDescriptorPathElementSliceV1 { + type: "SLICE"; + start?: number; + end?: number; +} + +export type CalldataDescriptorContainerPathV1 = "FROM" | "TO" | "VALUE"; +export type CalldataDescriptorPathLeafTypeV1 = + | "ARRAY_LEAF" + | "TUPLE_LEAF" + | "STATIC_LEAF" + | "DYNAMIC_LEAF"; +export type CalldataDescriptorTypeFamilyV1 = + | "UINT" + | "INT" + | "UFIXED" + | "FIXED" + | "ADDRESS" + | "BOOL" + | "BYTES" + | "STRING"; diff --git a/packages/signer/context-module/src/transaction/data/HttpTransactionDataSource.test.ts b/packages/signer/context-module/src/transaction/data/HttpTransactionDataSource.test.ts new file mode 100644 index 000000000..298954ddb --- /dev/null +++ b/packages/signer/context-module/src/transaction/data/HttpTransactionDataSource.test.ts @@ -0,0 +1,674 @@ +import axios from "axios"; +import { Left } from "purify-ts"; + +import type { ContextModuleConfig } from "@/config/model/ContextModuleConfig"; +import type { + CalldataEnumV1, + CalldataFieldV1, + CalldataTransactionInfoV1, +} from "@/transaction/data/CalldataDto"; +import { HttpTransactionDataSource } from "@/transaction/data/HttpTransactionDataSource"; +import type { TransactionDataSource } from "@/transaction/data/TransactionDataSource"; +import PACKAGE from "@root/package.json"; + +jest.mock("axios"); + +describe("HttpTransactionDataSource", () => { + let datasource: TransactionDataSource; + let transactionInfo: CalldataTransactionInfoV1; + let enums: CalldataEnumV1[]; + let fieldToken: CalldataFieldV1; + let fieldTrustedName: CalldataFieldV1; + let fieldNft: CalldataFieldV1; + let fieldAmount: CalldataFieldV1; + let fieldDatetime: CalldataFieldV1; + let fieldUnit: CalldataFieldV1; + let fieldDuration: CalldataFieldV1; + + beforeAll(() => { + jest.clearAllMocks(); + const config = { + cal: { + url: "https://crypto-assets-service.api.ledger.com/v1", + mode: "test", + branch: "main", + }, + } as ContextModuleConfig; + datasource = new HttpTransactionDataSource(config); + + transactionInfo = { + descriptor: { + data: "0001000108000000000000000102147d2768de32b0b80b7a3454c06bdac94a69ddc7a9030469328dec04207d5e9ed0004b8035b164edd9d78c37415ad6b1d123be4943d0abd5a50035cae3050857697468647261770604416176650708416176652044414f081068747470733a2f2f616176652e636f6d0a045fc4ba9c", + signatures: { + test: "3045022100eb67599abfd9c7360b07599a2a2cb769c6e3f0f74e1e52444d788c8f577a16d20220402e92b0adbf97d890fa2f9654bc30c7bd70dacabe870f160e6842d9eb73d36f", + }, + }, + }; + enums = [ + { descriptor: "0001000401000501010606737461626c65" }, + { descriptor: "00010004010005010206087661726961626c65" }, + ]; + fieldAmount = createFieldWithoutReference("FROM", "UFIXED", "AMOUNT", "06"); + fieldDatetime = createFieldWithoutReference( + "TO", + "FIXED", + "DATETIME", + "07", + ); + fieldUnit = createFieldWithoutReference("TO", "BOOL", "UNIT", "08"); + fieldDuration = createFieldWithoutReference( + "VALUE", + "INT", + "DURATION", + "09", + ); + fieldToken = { + param: { + value: { + binary_path: { + elements: [ + { + type: "TUPLE", + offset: 0, + }, + { + type: "LEAF", + leaf_type: "STATIC_LEAF", + }, + ], + }, + type_family: "UINT", + type_size: 32, + }, + type: "TOKEN_AMOUNT", + token: { + binary_path: { + elements: [ + { + type: "ARRAY", + start: 0, + length: 5, + weight: 1, + }, + { + type: "LEAF", + leaf_type: "DYNAMIC_LEAF", + }, + ], + }, + type_family: "ADDRESS", + type_size: 20, + }, + }, + descriptor: + "0001000112416d6f756e7420746f20776974686472617702010203580001000115000100010101020120030a000100010200010401030215000100010105020114030a000100010200000401030420ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff05034d6178", + }; + fieldTrustedName = { + param: { + value: { + binary_path: "TO", + type_family: "STRING", + type_size: 20, + }, + type: "TRUSTED_NAME", + types: ["eoa"], + sources: ["ens", "unstoppable_domain"], + }, + descriptor: + "000100010c546f20726563697069656e7402010803230001000115000100010105020114030a00010001020002040103020101030402030402", + }; + fieldNft = { + param: { + value: { + binary_path: { + elements: [ + { + type: "ARRAY", + weight: 2, + }, + { + type: "LEAF", + leaf_type: "TUPLE_LEAF", + }, + { + type: "SLICE", + end: 2, + }, + ], + }, + type_family: "BYTES", + type_size: 20, + }, + collection: { + binary_path: { + elements: [ + { + type: "REF", + }, + { + type: "LEAF", + leaf_type: "ARRAY_LEAF", + }, + { + type: "SLICE", + start: 1, + }, + ], + }, + type_family: "INT", + type_size: 20, + }, + type: "NFT", + }, + descriptor: + "000100010c546f20726563697069656e7402010803230001000115000100010105020114", + }; + }); + + function createFieldWithoutReference( + binary_path: unknown, + type_family: string, + type: string, + descriptor: string, + ): CalldataFieldV1 { + return { + param: { + value: { + binary_path, + type_family, + type_size: 32, + }, + type, + }, + descriptor, + } as CalldataFieldV1; + } + + function createCalldata( + transactionInfo: unknown, + enums: unknown[], + fields: unknown[], + ): unknown { + return { + descriptors_calldata: { + "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9": { + "0x69328dec": { + type: "calldata", + version: "v1", + transaction_info: transactionInfo, + enums: enums, + fields: fields, + }, + }, + }, + }; + } + + it("should call axios with the ledger client version header", async () => { + // GIVEN + const version = `context-module/${PACKAGE.version}`; + const requestSpy = jest.fn(() => Promise.resolve({ data: [] })); + jest.spyOn(axios, "request").mockImplementation(requestSpy); + + // WHEN + await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x0abc", + selector: "0x01ff", + }); + + // THEN + expect(requestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { "X-Ledger-Client-Version": version }, + }), + ); + }); + + it("should return an error when axios throws an error", async () => { + // GIVEN + jest.spyOn(axios, "request").mockRejectedValue(new Error()); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x0abc", + selector: "0x01ff", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to fetch transaction informations: Error", + ), + ), + ); + }); + + it("should return an error when no payload is returned", async () => { + // GIVEN + const response = { data: { test: "" } }; + jest.spyOn(axios, "request").mockResolvedValue(response); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x0abc", + selector: "0x01ff", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: No generic descriptor for contract 0x0abc", + ), + ), + ); + }); + + it("should return an error when selector is not found", async () => { + // GIVEN + const calldataDTO = createCalldata(transactionInfo, enums, [fieldToken]); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x01fe", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Invalid response for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x01fe", + ), + ), + ); + }); + + it("Calldata with fields references and enums", async () => { + // GIVEN + const calldataDTO = createCalldata(transactionInfo, enums, [ + fieldToken, + fieldTrustedName, + fieldNft, + ]); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80B7a3454c06bdac94a69ddc7a9", + selector: "0x69328dEc", + }); + + // THEN + expect(result.extract()).toEqual([ + { + payload: + "0001000108000000000000000102147d2768de32b0b80b7a3454c06bdac94a69ddc7a9030469328dec04207d5e9ed0004b8035b164edd9d78c37415ad6b1d123be4943d0abd5a50035cae3050857697468647261770604416176650708416176652044414f081068747470733a2f2f616176652e636f6d0a045fc4ba9c3045022100eb67599abfd9c7360b07599a2a2cb769c6e3f0f74e1e52444d788c8f577a16d20220402e92b0adbf97d890fa2f9654bc30c7bd70dacabe870f160e6842d9eb73d36f", + type: "transactionInfo", + }, + { + payload: "0001000401000501010606737461626c65", + type: "enum", + }, + { + payload: "00010004010005010206087661726961626c65", + type: "enum", + }, + { + payload: fieldToken.descriptor, + type: "transactionFieldDescription", + reference: { + type: "token", + valuePath: [ + { + type: "ARRAY", + start: 0, + length: 5, + itemSize: 1, + }, + { + type: "LEAF", + leafType: "DYNAMIC_LEAF", + }, + ], + }, + }, + { + payload: fieldTrustedName.descriptor, + type: "transactionFieldDescription", + reference: { + type: "trustedName", + valuePath: "TO", + types: ["eoa"], + sources: ["ens", "unstoppable_domain"], + }, + }, + { + payload: fieldNft.descriptor, + type: "transactionFieldDescription", + reference: { + type: "nft", + valuePath: [ + { + type: "REF", + }, + { + type: "LEAF", + leafType: "ARRAY_LEAF", + }, + { + type: "SLICE", + start: 1, + }, + ], + }, + }, + ]); + }); + + it("Calldata without fields references", async () => { + // GIVEN + const calldataDTO = createCalldata( + transactionInfo, + [], + [fieldAmount, fieldDatetime, fieldUnit, fieldDuration], + ); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result.extract()).toEqual([ + { + payload: + "0001000108000000000000000102147d2768de32b0b80b7a3454c06bdac94a69ddc7a9030469328dec04207d5e9ed0004b8035b164edd9d78c37415ad6b1d123be4943d0abd5a50035cae3050857697468647261770604416176650708416176652044414f081068747470733a2f2f616176652e636f6d0a045fc4ba9c3045022100eb67599abfd9c7360b07599a2a2cb769c6e3f0f74e1e52444d788c8f577a16d20220402e92b0adbf97d890fa2f9654bc30c7bd70dacabe870f160e6842d9eb73d36f", + type: "transactionInfo", + }, + { + type: "transactionFieldDescription", + payload: fieldAmount.descriptor, + }, + { + type: "transactionFieldDescription", + payload: fieldDatetime.descriptor, + }, + { + type: "transactionFieldDescription", + payload: fieldUnit.descriptor, + }, + { + type: "transactionFieldDescription", + payload: fieldDuration.descriptor, + }, + ]); + }); + + it("should return an error when calldata is not correctly formatted", async () => { + // GIVEN + const calldataDTO = { + descriptors_calldata: { + "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9": { + "0x69328dec": { + type: "calldat", + version: "v1", + transaction_info: transactionInfo, + enums: enums, + fields: [fieldToken], + }, + }, + }, + }; + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x69328dec", + ), + ), + ); + }); + + it("should return an error when transactionInfo is not correctly formatted", async () => { + // GIVEN + const calldataDTO = createCalldata( + { + descriptor: { + data: "1234", + signatures: { + prod: "1234", + }, + }, + }, + enums, + [fieldToken], + ); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x69328dec", + ), + ), + ); + }); + + it("should return an error when enum is not correctly formatted", async () => { + // GIVEN + const calldataDTO = createCalldata( + transactionInfo, + ["badEnum"], + [fieldToken], + ); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x69328dec", + ), + ), + ); + }); + + it("should return an error when field is not correctly formatted", async () => { + // GIVEN + const calldataDTO = createCalldata( + transactionInfo, + [], + [{ descriptor: 3 }], + ); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x69328dec", + ), + ), + ); + }); + + it("should return an error when field value is not correctly formatted", async () => { + // GIVEN + const field = { + param: { + value: { + binary_path: "TO", + type_family: "UNKNOWN", + type_size: 20, + }, + type: "DATETIME", + }, + descriptor: "000100010c546f20726563697069667", + }; + const calldataDTO = createCalldata(transactionInfo, [], [field]); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x69328dec", + ), + ), + ); + }); + + it("should return an error when field container path is not correctly formatted", async () => { + // GIVEN + const field = { + param: { + value: { + binary_path: "UNKNOWN", + type_family: "ADDRESS", + type_size: 20, + }, + type: "DATETIME", + }, + descriptor: "000100010c546f20726563697069667", + }; + const calldataDTO = createCalldata(transactionInfo, [], [field]); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x69328dec", + ), + ), + ); + }); + + it("should return an error when field calldata path is not correctly formatted", async () => { + // GIVEN + const field = { + param: { + value: { + binary_path: { + elements: [ + { + type: "UNKNOWN", + }, + ], + }, + type_family: "ADDRESS", + type_size: 20, + }, + type: "DATETIME", + }, + descriptor: "000100010c546f20726563697069667", + }; + const calldataDTO = createCalldata(transactionInfo, [], [field]); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x69328dec", + ), + ), + ); + }); + + it("should return an error when field type is not correctly formatted", async () => { + // GIVEN + const field = { + param: { + value: { + binary_path: "TO", + type_family: "ADDRESS", + type_size: 20, + }, + type: "UNKNOWN", + }, + descriptor: "000100010c546f20726563697069667", + }; + const calldataDTO = createCalldata(transactionInfo, [], [field]); + jest.spyOn(axios, "request").mockResolvedValue({ data: [calldataDTO] }); + + // WHEN + const result = await datasource.getTransactionDescriptors({ + chainId: 1, + address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", + selector: "0x69328dec", + }); + + // THEN + expect(result).toEqual( + Left( + new Error( + "[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9 and selector 0x69328dec", + ), + ), + ); + }); +}); diff --git a/packages/signer/context-module/src/transaction/data/HttpTransactionDataSource.ts b/packages/signer/context-module/src/transaction/data/HttpTransactionDataSource.ts new file mode 100644 index 000000000..fe6fe74db --- /dev/null +++ b/packages/signer/context-module/src/transaction/data/HttpTransactionDataSource.ts @@ -0,0 +1,292 @@ +import axios from "axios"; +import { inject, injectable } from "inversify"; +import { Either, Left, Right } from "purify-ts"; + +import { configTypes } from "@/config/di/configTypes"; +import type { + ContextModuleCalMode, + ContextModuleConfig, +} from "@/config/model/ContextModuleConfig"; +import { + ClearSignContextReference, + ClearSignContextSuccess, + ClearSignContextType, +} from "@/shared/model/ClearSignContext"; +import { GenericPath } from "@/shared/model/GenericPath"; +import PACKAGE from "@root/package.json"; + +import { + CalldataDescriptor, + CalldataDescriptorContainerPathV1, + CalldataDescriptorParam, + CalldataDescriptorPathElementsV1, + CalldataDescriptorPathElementV1, + CalldataDescriptorV1, + CalldataDescriptorValueV1, + CalldataDto, + CalldataEnumV1, + CalldataFieldV1, + CalldataTransactionInfoV1, +} from "./CalldataDto"; +import { + GetTransactionDescriptorsParams, + TransactionDataSource, +} from "./TransactionDataSource"; + +@injectable() +export class HttpTransactionDataSource implements TransactionDataSource { + constructor( + @inject(configTypes.Config) private readonly config: ContextModuleConfig, + ) {} + public async getTransactionDescriptors({ + chainId, + address, + selector, + }: GetTransactionDescriptorsParams): Promise< + Either + > { + let calldata: CalldataDto | undefined; + try { + const response = await axios.request({ + method: "GET", + url: `${this.config.cal.url}/dapps`, + params: { + output: "descriptors_calldata", + chain_id: chainId, + contracts: address, + ref: `branch:${this.config.cal.branch}`, + }, + headers: { + "X-Ledger-Client-Version": `context-module/${PACKAGE.version}`, + }, + }); + calldata = response.data?.[0]; + } catch (error) { + return Left( + new Error( + `[ContextModule] HttpTransactionDataSource: Failed to fetch transaction informations: ${error}`, + ), + ); + } + + if (!calldata) { + return Left( + new Error( + `[ContextModule] HttpTransactionDataSource: No generic descriptor for contract ${address}`, + ), + ); + } + + // Normalize the address and selector + address = address.toLowerCase(); + selector = `0x${selector.slice(2).toLowerCase()}`; + + const calldataDescriptor = + calldata.descriptors_calldata?.[address]?.[selector]; + if (!calldataDescriptor) { + return Left( + new Error( + `[ContextModule] HttpTransactionDataSource: Invalid response for contract ${address} and selector ${selector}`, + ), + ); + } + + if ( + !this.isCalldataDescriptorV1(calldataDescriptor, this.config.cal.mode) + ) { + return Left( + new Error( + `[ContextModule] HttpTransactionDataSource: Failed to decode transaction descriptor for contract ${address} and selector ${selector}`, + ), + ); + } + + const infoData = calldataDescriptor.transaction_info.descriptor.data; + const infoSignature = + calldataDescriptor.transaction_info.descriptor.signatures[ + this.config.cal.mode + ]; + const info: ClearSignContextSuccess = { + type: ClearSignContextType.TRANSACTION_INFO, + payload: `${infoData}${infoSignature}`, + }; + const enums: ClearSignContextSuccess[] = calldataDescriptor.enums.map( + (e) => ({ + type: ClearSignContextType.ENUM, + payload: e.descriptor, + }), + ); + const fields: ClearSignContextSuccess[] = calldataDescriptor.fields.map( + (field) => ({ + type: ClearSignContextType.TRANSACTION_FIELD_DESCRIPTION, + payload: field.descriptor, + reference: this.getReference(field.param), + }), + ); + return Right([info, ...enums, ...fields]); + } + + private getReference( + param: CalldataDescriptorParam, + ): ClearSignContextReference | undefined { + if (param.type === "TOKEN_AMOUNT" && param.token !== undefined) { + return { + type: ClearSignContextType.TOKEN, + valuePath: this.toGenericPath(param.token.binary_path), + }; + } else if (param.type === "NFT") { + return { + type: ClearSignContextType.NFT, + valuePath: this.toGenericPath(param.collection.binary_path), + }; + } else if (param.type === "TRUSTED_NAME") { + return { + type: ClearSignContextType.TRUSTED_NAME, + valuePath: this.toGenericPath(param.value.binary_path), + types: param.types, + sources: param.sources, + }; + } + return undefined; + } + + private toGenericPath( + path: CalldataDescriptorContainerPathV1 | CalldataDescriptorPathElementsV1, + ): GenericPath { + if (typeof path !== "object") { + return path; + } + return path.elements.map((element) => { + if (element.type === "ARRAY") { + const { weight: itemSize, ...rest } = element; + return { + itemSize, + ...rest, + }; + } else if (element.type === "LEAF") { + const { leaf_type: leafType, ...rest } = element; + return { + leafType, + ...rest, + }; + } + return element; + }); + } + + private isCalldataDescriptorV1( + data: CalldataDescriptor, + mode: ContextModuleCalMode, + ): data is CalldataDescriptorV1 & { + transaction_info: { + descriptor: { + signatures: { [key in ContextModuleCalMode]: string }; + }; + }; + } { + return ( + typeof data === "object" && + data.type === "calldata" && + data.version === "v1" && + this.isTransactionInfoV1(data.transaction_info, mode) && + Array.isArray(data.enums) && + Array.isArray(data.fields) && + data.enums.every((e) => this.isEnumV1(e)) && + data.fields.every((f) => this.isFieldV1(f)) + ); + } + + private isTransactionInfoV1( + data: CalldataTransactionInfoV1, + mode: ContextModuleCalMode, + ): data is CalldataTransactionInfoV1 & { + descriptor: { + signatures: { [key in ContextModuleCalMode]: string }; + }; + } { + return ( + typeof data === "object" && + typeof data.descriptor === "object" && + typeof data.descriptor.data === "string" && + typeof data.descriptor.signatures === "object" && + typeof data.descriptor.signatures[mode] === "string" + ); + } + + private isEnumV1(data: CalldataEnumV1): boolean { + return typeof data === "object" && typeof data.descriptor === "string"; + } + + private isFieldV1(data: CalldataFieldV1): boolean { + return ( + typeof data === "object" && + typeof data.descriptor === "string" && + typeof data.param === "object" && + typeof data.param.value === "object" && + this.isDescriptorValueV1(data.param.value) && + (data.param.type === "RAW" || + data.param.type === "AMOUNT" || + data.param.type === "DATETIME" || + data.param.type === "DURATION" || + data.param.type === "UNIT" || + data.param.type === "ENUM" || + (data.param.type === "NFT" && + this.isDescriptorValueV1(data.param.collection)) || + (data.param.type === "TOKEN_AMOUNT" && + (data.param.token === undefined || + this.isDescriptorValueV1(data.param.token))) || + (data.param.type === "TRUSTED_NAME" && + Array.isArray(data.param.types) && + Array.isArray(data.param.sources) && + data.param.types.every((t) => typeof t === "string") && + data.param.sources.every((t) => typeof t === "string"))) + ); + } + + private isDescriptorValueV1(data: CalldataDescriptorValueV1): boolean { + return ( + typeof data === "object" && + typeof data.type_family === "string" && + [ + "UINT", + "INT", + "UFIXED", + "FIXED", + "ADDRESS", + "BOOL", + "BYTES", + "STRING", + ].includes(data.type_family) && + (typeof data.type_size === "undefined" || + typeof data.type_size === "number") && + ((typeof data.binary_path === "string" && + ["FROM", "TO", "VALUE"].includes(data.binary_path)) || + (typeof data.binary_path === "object" && + Array.isArray(data.binary_path.elements) && + data.binary_path.elements.every((e) => this.isPathElementV1(e)))) + ); + } + + private isPathElementV1(data: CalldataDescriptorPathElementV1): boolean { + return ( + typeof data === "object" && + (data.type === "REF" || + (data.type === "TUPLE" && typeof data.offset === "number") || + (data.type === "ARRAY" && + typeof data.weight === "number" && + (typeof data.start === "undefined" || + typeof data.start === "number") && + (typeof data.length === "undefined" || + typeof data.length === "number")) || + (data.type === "LEAF" && + typeof data.leaf_type === "string" && + ["ARRAY_LEAF", "TUPLE_LEAF", "STATIC_LEAF", "DYNAMIC_LEAF"].includes( + data.leaf_type, + )) || + (data.type === "SLICE" && + (typeof data.start === "undefined" || + typeof data.start === "number") && + (typeof data.end === "undefined" || typeof data.end === "number"))) + ); + } +} diff --git a/packages/signer/context-module/src/transaction/data/TransactionDataSource.ts b/packages/signer/context-module/src/transaction/data/TransactionDataSource.ts new file mode 100644 index 000000000..4f2b3ec70 --- /dev/null +++ b/packages/signer/context-module/src/transaction/data/TransactionDataSource.ts @@ -0,0 +1,16 @@ +import { type HexaString } from "@ledgerhq/device-management-kit"; +import { type Either } from "purify-ts"; + +import { type ClearSignContextSuccess } from "@/shared/model/ClearSignContext"; + +export type GetTransactionDescriptorsParams = { + address: string; + chainId: number; + selector: HexaString; +}; + +export interface TransactionDataSource { + getTransactionDescriptors( + params: GetTransactionDescriptorsParams, + ): Promise>; +} diff --git a/packages/signer/context-module/src/transaction/di/transactionModuleFactory.ts b/packages/signer/context-module/src/transaction/di/transactionModuleFactory.ts new file mode 100644 index 000000000..d25e7b702 --- /dev/null +++ b/packages/signer/context-module/src/transaction/di/transactionModuleFactory.ts @@ -0,0 +1,13 @@ +import { ContainerModule } from "inversify"; + +import { HttpTransactionDataSource } from "@/transaction/data/HttpTransactionDataSource"; +import { transactionTypes } from "@/transaction/di/transactionTypes"; +import { TransactionContextLoader } from "@/transaction/domain/TransactionContextLoader"; + +export const transactionModuleFactory = () => + new ContainerModule((bind, _unbind, _isBound, _rebind) => { + bind(transactionTypes.TransactionDataSource).to(HttpTransactionDataSource); + bind(transactionTypes.TransactionContextLoader).to( + TransactionContextLoader, + ); + }); diff --git a/packages/signer/context-module/src/transaction/di/transactionTypes.ts b/packages/signer/context-module/src/transaction/di/transactionTypes.ts new file mode 100644 index 000000000..9b0677edb --- /dev/null +++ b/packages/signer/context-module/src/transaction/di/transactionTypes.ts @@ -0,0 +1,4 @@ +export const transactionTypes = { + TransactionDataSource: Symbol.for("TransactionDataSource"), + TransactionContextLoader: Symbol.for("TransactionContextLoader"), +}; diff --git a/packages/signer/context-module/src/transaction/domain/TransactionContextLoader.test.ts b/packages/signer/context-module/src/transaction/domain/TransactionContextLoader.test.ts new file mode 100644 index 000000000..48fa12d4e --- /dev/null +++ b/packages/signer/context-module/src/transaction/domain/TransactionContextLoader.test.ts @@ -0,0 +1,135 @@ +import { Left, Right } from "purify-ts"; + +import { ClearSignContextType } from "@/shared/model/ClearSignContext"; +import type { TransactionContext } from "@/shared/model/TransactionContext"; +import type { TransactionDataSource } from "@/transaction/data/TransactionDataSource"; +import { TransactionContextLoader } from "@/transaction/domain/TransactionContextLoader"; + +describe("TransactionContextLoader", () => { + const getTransactionDescriptorsMock = jest.fn(); + const mockTransactionDataSource: TransactionDataSource = { + getTransactionDescriptors: getTransactionDescriptorsMock, + }; + const loader = new TransactionContextLoader(mockTransactionDataSource); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return an empty array if no destination address is provided", async () => { + // GIVEN + const transaction = {} as TransactionContext; + + // WHEN + const result = await loader.load(transaction); + + // THEN + expect(result).toEqual([]); + }); + + it("should return an empty array if data is undefined", async () => { + // GIVEN + const transaction = { to: "0x0" } as TransactionContext; + + // WHEN + const result = await loader.load(transaction); + + // THEN + expect(result).toEqual([]); + }); + + it("should return an empty array if no data provided", async () => { + // GIVEN + const transaction = { to: "0x0", data: "0x" } as TransactionContext; + + // WHEN + const result = await loader.load(transaction); + + // THEN + expect(result).toEqual([]); + }); + + it("should return an error if selector is invalid", async () => { + // GIVEN + const transaction = { + to: "0x7", + chainId: 3, + data: "0xzf68b302000000000000000000000000000000000000000000000000000000000002", + } as TransactionContext; + + // WHEN + const result = await loader.load(transaction); + + // THEN + expect(result).toEqual([ + { + type: ClearSignContextType.ERROR, + error: new Error("Invalid selector"), + }, + ]); + }); + + it("should return an error if data source fails", async () => { + // GIVEN + getTransactionDescriptorsMock.mockResolvedValue( + Left(new Error("data source error")), + ); + const transaction = { + to: "0x7", + chainId: 3, + data: "0xaf68b302000000000000000000000000000000000000000000000000000000000002", + } as TransactionContext; + + // WHEN + const result = await loader.load(transaction); + + // THEN + expect(getTransactionDescriptorsMock).toHaveBeenCalledWith({ + address: "0x7", + chainId: 3, + selector: "0xaf68b302", + }); + expect(result).toEqual([ + { + type: ClearSignContextType.ERROR, + error: new Error("data source error"), + }, + ]); + }); + + it("should return the contexts on success", async () => { + // GIVEN + getTransactionDescriptorsMock.mockResolvedValue( + Right([ + { + type: ClearSignContextType.TRANSACTION_INFO, + payload: "1234567890", + }, + { + type: ClearSignContextType.TRANSACTION_FIELD_DESCRIPTION, + payload: "deadbeef", + }, + ]), + ); + const transaction = { + to: "0x7", + chainId: 3, + data: "0xaf68b302000000000000000000000000000000000000000000000000000000000002", + } as TransactionContext; + + // WHEN + const result = await loader.load(transaction); + + // THEN + expect(result).toEqual([ + { + type: ClearSignContextType.TRANSACTION_INFO, + payload: "1234567890", + }, + { + type: ClearSignContextType.TRANSACTION_FIELD_DESCRIPTION, + payload: "deadbeef", + }, + ]); + }); +}); diff --git a/packages/signer/context-module/src/transaction/domain/TransactionContextLoader.ts b/packages/signer/context-module/src/transaction/domain/TransactionContextLoader.ts new file mode 100644 index 000000000..75fbc4e16 --- /dev/null +++ b/packages/signer/context-module/src/transaction/domain/TransactionContextLoader.ts @@ -0,0 +1,52 @@ +import { isHexaString } from "@ledgerhq/device-management-kit"; +import { inject, injectable } from "inversify"; + +import { ContextLoader } from "@/shared/domain/ContextLoader"; +import { + ClearSignContext, + ClearSignContextType, +} from "@/shared/model/ClearSignContext"; +import { TransactionContext } from "@/shared/model/TransactionContext"; +import type { TransactionDataSource } from "@/transaction/data/TransactionDataSource"; +import { transactionTypes } from "@/transaction/di/transactionTypes"; + +@injectable() +export class TransactionContextLoader implements ContextLoader { + constructor( + @inject(transactionTypes.TransactionDataSource) + private transactionDataSource: TransactionDataSource, + ) {} + + async load(transaction: TransactionContext): Promise { + if (!transaction.to || !transaction.data || transaction.data === "0x") { + return []; + } + + const selector = transaction.data.slice(0, 10); + + if (!isHexaString(selector)) { + return [ + { + type: ClearSignContextType.ERROR, + error: new Error("Invalid selector"), + }, + ]; + } + + const result = await this.transactionDataSource.getTransactionDescriptors({ + address: transaction.to, + chainId: transaction.chainId, + selector, + }); + + return result.caseOf({ + Left: (error): ClearSignContext[] => [ + { + type: ClearSignContextType.ERROR, + error, + }, + ], + Right: (contexts): ClearSignContext[] => contexts, + }); + } +} diff --git a/packages/signer/signer-eth/src/api/SignerEthBuilder.test.ts b/packages/signer/signer-eth/src/api/SignerEthBuilder.test.ts index 86cfb7286..a6ccbbef5 100644 --- a/packages/signer/signer-eth/src/api/SignerEthBuilder.test.ts +++ b/packages/signer/signer-eth/src/api/SignerEthBuilder.test.ts @@ -32,7 +32,7 @@ describe("SignerEthBuilder", () => { expect( (builder["_contextModule"] as unknown as { _loaders: ContextLoader[] }) ._loaders, - ).toHaveLength(4); + ).toHaveLength(5); }); test("should instanciate with custom context module", () => { diff --git a/packages/signer/signer-eth/src/internal/app-binder/task/ProvideTransactionContextTask.ts b/packages/signer/signer-eth/src/internal/app-binder/task/ProvideTransactionContextTask.ts index 2a8aad424..07a4c405a 100644 --- a/packages/signer/signer-eth/src/internal/app-binder/task/ProvideTransactionContextTask.ts +++ b/packages/signer/signer-eth/src/internal/app-binder/task/ProvideTransactionContextTask.ts @@ -124,6 +124,7 @@ export class ProvideTransactionContextTask { ); } case ClearSignContextType.ENUM: + case ClearSignContextType.TRUSTED_NAME: case ClearSignContextType.TRANSACTION_FIELD_DESCRIPTION: case ClearSignContextType.TRANSACTION_INFO: { return CommandResultFactory({ diff --git a/packages/signer/signer-eth/src/internal/app-binder/task/ProvideTransactionGenericContextTask.ts b/packages/signer/signer-eth/src/internal/app-binder/task/ProvideTransactionGenericContextTask.ts index 5ceb36d3e..5c70d2892 100644 --- a/packages/signer/signer-eth/src/internal/app-binder/task/ProvideTransactionGenericContextTask.ts +++ b/packages/signer/signer-eth/src/internal/app-binder/task/ProvideTransactionGenericContextTask.ts @@ -183,6 +183,13 @@ export class ProvideTransactionGenericContextTask { }), ); } + case ClearSignContextType.TRUSTED_NAME: { + return CommandResultFactory({ + error: new InvalidStatusWordError( + "The context type [TRUSTED_NAME] is not implemented yet", + ), + }); + } case ClearSignContextType.EXTERNAL_PLUGIN: { return CommandResultFactory({ error: new InvalidStatusWordError(