Skip to content

Commit

Permalink
feat(context-module): add external plugin loader
Browse files Browse the repository at this point in the history
  • Loading branch information
aussedatlo committed May 10, 2024
1 parent b76691c commit 9b07f9b
Show file tree
Hide file tree
Showing 8 changed files with 358 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<DappInfos | undefined>;
}
Original file line number Diff line number Diff line change
@@ -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<DappInfos | undefined> {
const dappInfos = await axios.request<DappResponse[]>({
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) };
}
}
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
});
Original file line number Diff line number Diff line change
@@ -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}`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { AbiFunction } from "../data/DappResponse";

export type Abi = AbiFunction[];
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { SelectorDetails } from "./SelectorDetails";

export type DappInfos = { selectorDetails: SelectorDetails; abi: string } | undefined;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type SelectorDetails = {
plugin: string;
signature: string;
serializedData: string;
method: string;
erc20OfInterest: string[];
};

0 comments on commit 9b07f9b

Please sign in to comment.