From 9b07f9bd7ff4cff315ebceb0e0ff96f0a6b6ceb6 Mon Sep 17 00:00:00 2001 From: Louis Aussedat Date: Mon, 6 May 2024 11:41:52 +0200 Subject: [PATCH] feat(context-module): add external plugin loader --- .../src/external-plugin/data/DappResponse.ts | 61 +++++++++ .../data/ExternalPluginDataSource.ts | 11 ++ .../data/HttpExternalPluginDataSource.ts | 42 ++++++ .../ExternalPluginContextLoader.test.ts | 124 ++++++++++++++++++ .../domain/ExternalPluginContextLoader.ts | 107 +++++++++++++++ .../src/external-plugin/model/Abi.ts | 3 + .../src/external-plugin/model/DappInfos.ts | 3 + .../external-plugin/model/SelectorDetails.ts | 7 + 8 files changed, 358 insertions(+) create mode 100644 libs/ledgerjs/packages/context-module/src/external-plugin/data/DappResponse.ts create mode 100644 libs/ledgerjs/packages/context-module/src/external-plugin/data/ExternalPluginDataSource.ts create mode 100644 libs/ledgerjs/packages/context-module/src/external-plugin/data/HttpExternalPluginDataSource.ts create mode 100644 libs/ledgerjs/packages/context-module/src/external-plugin/domain/ExternalPluginContextLoader.test.ts create mode 100644 libs/ledgerjs/packages/context-module/src/external-plugin/domain/ExternalPluginContextLoader.ts create mode 100644 libs/ledgerjs/packages/context-module/src/external-plugin/model/Abi.ts create mode 100644 libs/ledgerjs/packages/context-module/src/external-plugin/model/DappInfos.ts create mode 100644 libs/ledgerjs/packages/context-module/src/external-plugin/model/SelectorDetails.ts diff --git a/libs/ledgerjs/packages/context-module/src/external-plugin/data/DappResponse.ts b/libs/ledgerjs/packages/context-module/src/external-plugin/data/DappResponse.ts new file mode 100644 index 000000000000..91fc0f60d7e5 --- /dev/null +++ b/libs/ledgerjs/packages/context-module/src/external-plugin/data/DappResponse.ts @@ -0,0 +1,61 @@ +export interface DappResponse { + b2c: B2c; + abis: Abis; + b2c_signatures: B2cSignatures; +} + +export interface B2c { + blockchainName: string; + chainId: number; + contracts: Contract[]; + name: string; +} + +interface Contract { + address: string; + contractName: string; + selectors: { [selector: string]: ContractSelector }; +} + +interface ContractSelector { + erc20OfInterest: string[]; + method: string; + plugin: string; +} + +interface Abis { + [address: string]: AbiFunction[]; +} + +export interface AbiFunction { + type: string; + stateMutability?: string; + name?: string; + inputs?: AbiInput[]; + outputs?: AbiOutput[]; + anonymous?: false; +} + +interface AbiInput { + name: string; + internalType: string; + indexed?: boolean; +} + +interface AbiOutput { + name: string; + internalType: string; + components?: unknown; // FIXME: type +} + +export interface B2cSignatures { + [address: string]: { + [selector: string]: B2cSignature; + }; +} + +interface B2cSignature { + plugin: string; + serialized_data: string; + signature: string; +} diff --git a/libs/ledgerjs/packages/context-module/src/external-plugin/data/ExternalPluginDataSource.ts b/libs/ledgerjs/packages/context-module/src/external-plugin/data/ExternalPluginDataSource.ts new file mode 100644 index 000000000000..da76cd37b890 --- /dev/null +++ b/libs/ledgerjs/packages/context-module/src/external-plugin/data/ExternalPluginDataSource.ts @@ -0,0 +1,11 @@ +import { DappInfos } from "../model/DappInfos"; + +export type GetDappInfos = { + address: string; + selector: `0x${string}`; + chainId: number; +}; + +export interface ExternalPluginDataSource { + getDappInfos(params: GetDappInfos): Promise; +} diff --git a/libs/ledgerjs/packages/context-module/src/external-plugin/data/HttpExternalPluginDataSource.ts b/libs/ledgerjs/packages/context-module/src/external-plugin/data/HttpExternalPluginDataSource.ts new file mode 100644 index 000000000000..954f6781dc99 --- /dev/null +++ b/libs/ledgerjs/packages/context-module/src/external-plugin/data/HttpExternalPluginDataSource.ts @@ -0,0 +1,42 @@ +import axios from "axios"; +import { ExternalPluginDataSource, GetDappInfos } from "./ExternalPluginDataSource"; +import { DappResponse } from "./DappResponse"; +import { DappInfos } from "../model/DappInfos"; +import { SelectorDetails } from "../model/SelectorDetails"; + +export class HttpExternalPluginDataSource implements ExternalPluginDataSource { + constructor() {} + + async getDappInfos({ chainId, address, selector }: GetDappInfos): Promise { + const dappInfos = await axios.request({ + method: "GET", + url: "https://crypto-assets-service.api.ledger.com/v1/dapps", + params: { output: "b2c,b2c_signatures,abis", chain_id: chainId, contracts: address }, + }); + + const { erc20OfInterest, method, plugin } = + dappInfos.data[0]?.b2c.contracts?.[0]?.selectors?.[selector] || {}; + const { signature, serialized_data: serializedData } = + dappInfos.data[0]?.b2c_signatures?.[address]?.[selector] || {}; + + if (!erc20OfInterest || !method || !plugin || !signature || !serializedData) { + return; + } + + const abi = dappInfos.data[0]?.abis?.[address]; + + if (!abi) { + return; + } + + const selectorDetails: SelectorDetails = { + method, + plugin, + erc20OfInterest, + signature, + serializedData, + }; + + return { selectorDetails, abi: JSON.stringify(abi) }; + } +} diff --git a/libs/ledgerjs/packages/context-module/src/external-plugin/domain/ExternalPluginContextLoader.test.ts b/libs/ledgerjs/packages/context-module/src/external-plugin/domain/ExternalPluginContextLoader.test.ts new file mode 100644 index 000000000000..bd59f7320889 --- /dev/null +++ b/libs/ledgerjs/packages/context-module/src/external-plugin/domain/ExternalPluginContextLoader.test.ts @@ -0,0 +1,124 @@ +import { LoaderOptions } from "../../shared/model/LoaderOptions"; +import { TokenDataSource } from "../../token/data/TokenDataSource"; +import { ExternalPluginDataSource } from "../data/ExternalPluginDataSource"; +import { ExternalPluginContextLoader } from "./ExternalPluginContextLoader"; +import { Transaction } from "../../shared/model/Transaction"; +import { DappInfos } from "../model/DappInfos"; + +const test2 = `[{"inputs":[{"internalType":"address payable","name":"_feeWallet","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"adapter","type":"address"}],"name":"AdapterInitialized","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"role","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"previousAdminRole","type":"bytes32"},{"indexed":true,"internalType":"bytes32","name":"newAdminRole","type":"bytes32"}],"name":"RoleAdminChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"role","type":"bytes32"},{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":true,"internalType":"address","name":"sender","type":"address"}],"name":"RoleGranted","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"role","type":"bytes32"},{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":true,"internalType":"address","name":"sender","type":"address"}],"name":"RoleRevoked","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"router","type":"address"}],"name":"RouterInitialized","type":"event"},{"stateMutability":"payable","type":"fallback"},{"inputs":[],"name":"DEFAULT_ADMIN_ROLE","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"ROUTER_ROLE","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"WHITELISTED_ROLE","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"key","type":"bytes32"}],"name":"getAdapterData","outputs":[{"internalType":"bytes","name":"","type":"bytes"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getFeeWallet","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes4","name":"selector","type":"bytes4"}],"name":"getImplementation","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"partner","type":"address"}],"name":"getPartnerFeeStructure","outputs":[{"components":[{"internalType":"uint256","name":"partnerShare","type":"uint256"},{"internalType":"bool","name":"noPositiveSlippage","type":"bool"},{"internalType":"bool","name":"positiveSlippageToUser","type":"bool"},{"internalType":"uint16","name":"feePercent","type":"uint16"},{"internalType":"string","name":"partnerId","type":"string"},{"internalType":"bytes","name":"data","type":"bytes"}],"internalType":"struct AugustusStorage.FeeStructure","name":"","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"}],"name":"getRoleAdmin","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"uint256","name":"index","type":"uint256"}],"name":"getRoleMember","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"}],"name":"getRoleMemberCount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"key","type":"bytes32"}],"name":"getRouterData","outputs":[{"internalType":"bytes","name":"","type":"bytes"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getTokenTransferProxy","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getVersion","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"grantRole","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"hasRole","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"adapter","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"initializeAdapter","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"router","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"initializeRouter","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"key","type":"bytes32"}],"name":"isAdapterInitialized","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"key","type":"bytes32"}],"name":"isRouterInitialized","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"partner","type":"address"},{"internalType":"uint256","name":"_partnerShare","type":"uint256"},{"internalType":"bool","name":"_noPositiveSlippage","type":"bool"},{"internalType":"bool","name":"_positiveSlippageToUser","type":"bool"},{"internalType":"uint16","name":"_feePercent","type":"uint16"},{"internalType":"string","name":"partnerId","type":"string"},{"internalType":"bytes","name":"_data","type":"bytes"}],"name":"registerPartner","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"renounceRole","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"revokeRole","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address payable","name":"_feeWallet","type":"address"}],"name":"setFeeWallet","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"selector","type":"bytes4"},{"internalType":"address","name":"implementation","type":"address"}],"name":"setImplementation","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"token","type":"address"},{"internalType":"address payable","name":"destination","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transferTokens","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}] + `; + +describe("ExternalPluginContextLoader", () => { + let mockTokenDataSource: TokenDataSource; + let mockExternalPluginDataSource: ExternalPluginDataSource; + const emptyTransaction = {} as Transaction; + const emptyOptions = {} as LoaderOptions; + // from https://crypto-assets-service.api.ledger.com/v1/dapps?output=b2c,b2c_signatures,abis&chain_id=10&contracts=0xdef171fe48cf0115b1d80b88dc8eab59176fee57 + const exampleDappInfos: DappInfos = { + abi: test2, + selectorDetails: { + erc20OfInterest: ["tokenIn"], + method: "swapOnUniswapV2Fork", + plugin: "Paraswap", + serializedData: "085061726173776170def171fe48cf0115b1d80b88dc8eab59176fee570b86a4c1", + signature: + "3045022100832052e09afece789911f4310118e40fbd04d16961257423435f29d43de7193a02203610a035156139cb63873317eba79365592de5fdb60da9b5735492a69f67bb00", + }, + }; + + beforeEach(() => { + jest.restoreAllMocks(); + mockTokenDataSource = { getTokenInfosPayload: jest.fn() }; + mockExternalPluginDataSource = { getDappInfos: jest.fn() }; + }); + + describe("load function", () => { + it("should return an empty array when no destination address", async () => { + const loader = new ExternalPluginContextLoader( + mockExternalPluginDataSource, + mockTokenDataSource, + ); + const promise = () => loader.load(emptyTransaction, emptyOptions); + + expect(promise()).resolves.toEqual([]); + }); + + it("should return an empty array when data is undefined", async () => { + const transaction = { to: "0x0" } as Transaction; + const loader = new ExternalPluginContextLoader( + mockExternalPluginDataSource, + mockTokenDataSource, + ); + + const result = await loader.load(transaction, emptyOptions); + + expect(result).toEqual([]); + }); + + it("should return an empty array when data is empty", async () => { + const transaction = { to: "0x0", data: "0x0" } as Transaction; + const loader = new ExternalPluginContextLoader( + mockExternalPluginDataSource, + mockTokenDataSource, + ); + + const result = await loader.load(transaction, emptyOptions); + + expect(result).toEqual([]); + }); + + it("should return an empty array when no dapp info is returned", async () => { + const transaction = { to: "0x0", data: "0x0" } as Transaction; + const loader = new ExternalPluginContextLoader( + mockExternalPluginDataSource, + mockTokenDataSource, + ); + jest.spyOn(mockExternalPluginDataSource, "getDappInfos").mockResolvedValue(undefined); + + const result = await loader.load(transaction, emptyOptions); + + expect(result).toEqual([]); + }); + + it("should return an empty array if no erc20OfInterest", async () => { + // TODO: fix this test + const transaction = { to: "0x0", data: "0x0" } as Transaction; + const loader = new ExternalPluginContextLoader( + mockExternalPluginDataSource, + mockTokenDataSource, + ); + jest.spyOn(mockExternalPluginDataSource, "getDappInfos").mockResolvedValue(exampleDappInfos); + + const result = await loader.load(transaction, emptyOptions); + + expect(result).toEqual([]); + }); + + it("should return a list of context response", async () => { + // TODO: fix this test + const transaction = { to: "0x0", data: "0x0" } as Transaction; + const loader = new ExternalPluginContextLoader( + mockExternalPluginDataSource, + mockTokenDataSource, + ); + jest.spyOn(mockExternalPluginDataSource, "getDappInfos").mockResolvedValue(exampleDappInfos); + + const result = await loader.load(transaction, emptyOptions); + + expect(result).toEqual([]); + }); + it("should return a list of context response", async () => { + // TODO: fix this test + const transaction = { to: "0x0", data: "0x0" } as Transaction; + const loader = new ExternalPluginContextLoader( + mockExternalPluginDataSource, + mockTokenDataSource, + ); + jest.spyOn(mockExternalPluginDataSource, "getDappInfos").mockResolvedValue(exampleDappInfos); + + const result = await loader.load(transaction, emptyOptions); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/libs/ledgerjs/packages/context-module/src/external-plugin/domain/ExternalPluginContextLoader.ts b/libs/ledgerjs/packages/context-module/src/external-plugin/domain/ExternalPluginContextLoader.ts new file mode 100644 index 000000000000..687937f7e365 --- /dev/null +++ b/libs/ledgerjs/packages/context-module/src/external-plugin/domain/ExternalPluginContextLoader.ts @@ -0,0 +1,107 @@ +import { ethers } from "ethers"; +import { ContextLoader } from "../../shared/domain/ContextLoader"; +import { LoaderOptions } from "../../shared/model/LoaderOptions"; +import { Transaction } from "../../shared/model/Transaction"; +import { TokenDataSource } from "../../token/data/TokenDataSource"; +import { ContextResponse } from "../../shared/model/ContextResponse"; +import { ExternalPluginDataSource } from "../data/ExternalPluginDataSource"; +import { Interface } from "ethers/lib/utils"; + +export class ExternalPluginContextLoader implements ContextLoader { + private _externalPluginDataSource: ExternalPluginDataSource; + private _tokenDataSource: TokenDataSource; + + constructor( + externalPluginDataSource: ExternalPluginDataSource, + tokenDataSource: TokenDataSource, + ) { + this._externalPluginDataSource = externalPluginDataSource; + this._tokenDataSource = tokenDataSource; + } + + async load(transaction: Transaction, _options: LoaderOptions) { + const response: ContextResponse[] = []; + + if (!transaction.to || !transaction.data || transaction.data === "0x") { + return []; + } + + const selector = transaction.data.slice(0, 10) as `0x${string}`; + + const dappInfos = await this._externalPluginDataSource.getDappInfos({ + address: transaction.to, + chainId: transaction.chainId, + selector, + }); + + if (!dappInfos) { + return []; + } + + console.log(dappInfos.abi); + const contractInterface = new Interface(dappInfos.abi); + console.log(contractInterface); + + const decodedCallData = contractInterface.decodeFunctionData( + dappInfos.selectorDetails.method, + transaction.data, + ); + + const addresses: string[] = []; + for (const erc20Path in dappInfos.selectorDetails.erc20OfInterest) { + const address = this.getAddressFromPath(erc20Path, decodedCallData); + addresses.push(address); + } + + // TODO: keep this case or it's impossible ? + if (addresses.length !== dappInfos.selectorDetails.erc20OfInterest.length) { + return [ + { + type: "error" as const, + error: new Error( + "[ContextModule] ExternalPluginContextLoader: Mismatch between erc20OfInterest and callData", + ), + }, + ]; + } + + const tokenPayloads = await Promise.all( + addresses.map(address => + this._tokenDataSource.getTokenInfosPayload({ address, chainId: transaction.chainId }), + ), + ); + + for (const payload in tokenPayloads) { + response.push({ type: "provideERC20TokenInformation" as const, payload }); + } + + response.push({ + type: "setExternalPlugin" as const, + payload: Buffer.concat([ + Buffer.from(dappInfos.selectorDetails.serializedData, "hex"), + Buffer.from(dappInfos.selectorDetails.signature, "hex"), + ]).toString("hex"), + }); + + return response; + } + + private getAddressFromPath(path: string, decodedCallData: ethers.utils.Result): `0x${string}` { + // ethers.utils.Result is a record string, any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let value: any = decodedCallData; + for (const key in path.split(".")) { + if (key === "-1" && Array.isArray(value)) { + value = value[value.length - 1]; + } else { + value = value[key]; + } + } + + if (typeof value !== "string" || !value.startsWith("0x")) { + throw new Error("[ContextModule] ExternalPluginContextLoader: Unable to get address"); + } + + return value as `0x${string}`; + } +} diff --git a/libs/ledgerjs/packages/context-module/src/external-plugin/model/Abi.ts b/libs/ledgerjs/packages/context-module/src/external-plugin/model/Abi.ts new file mode 100644 index 000000000000..86f39931601b --- /dev/null +++ b/libs/ledgerjs/packages/context-module/src/external-plugin/model/Abi.ts @@ -0,0 +1,3 @@ +import { AbiFunction } from "../data/DappResponse"; + +export type Abi = AbiFunction[]; diff --git a/libs/ledgerjs/packages/context-module/src/external-plugin/model/DappInfos.ts b/libs/ledgerjs/packages/context-module/src/external-plugin/model/DappInfos.ts new file mode 100644 index 000000000000..1a30c32f773e --- /dev/null +++ b/libs/ledgerjs/packages/context-module/src/external-plugin/model/DappInfos.ts @@ -0,0 +1,3 @@ +import { SelectorDetails } from "./SelectorDetails"; + +export type DappInfos = { selectorDetails: SelectorDetails; abi: string } | undefined; diff --git a/libs/ledgerjs/packages/context-module/src/external-plugin/model/SelectorDetails.ts b/libs/ledgerjs/packages/context-module/src/external-plugin/model/SelectorDetails.ts new file mode 100644 index 000000000000..372b51c37ab0 --- /dev/null +++ b/libs/ledgerjs/packages/context-module/src/external-plugin/model/SelectorDetails.ts @@ -0,0 +1,7 @@ +export type SelectorDetails = { + plugin: string; + signature: string; + serializedData: string; + method: string; + erc20OfInterest: string[]; +};