Skip to content

Commit

Permalink
Merge pull request #346 from forta-network/forta-1259-js-external-bot…
Browse files Browse the repository at this point in the history
…-support

JS support for external bots
  • Loading branch information
haseebrabbani authored Oct 13, 2023
2 parents 70f3219 + 37bb32e commit 4e4c71a
Show file tree
Hide file tree
Showing 7 changed files with 366 additions and 167 deletions.
4 changes: 3 additions & 1 deletion cli/di.container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,9 @@ export default function configureContainer(args: any = {}) {
getSubscriptionAlerts: asFunction(provideGetSubscriptionAlerts),
getTraceData: asFunction(provideGetTraceData),
getAgentLogs: asFunction(provideGetAgentLogs),
fortaApiUrl: asValue('https://api.forta.network'),
fortaApiUrl: asFunction((fortaConfig: FortaConfig) => {
return fortaConfig.fortaApiUrl || 'https://api.forta.network'
}),
polyscanApiUrl: asValue('https://api.polygonscan.com/api'),
traceRpcUrl: asFunction((fortaConfig: FortaConfig) => {
return fortaConfig.traceRpcUrl
Expand Down
117 changes: 115 additions & 2 deletions sdk/alerts.api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,55 @@
import axios from "axios";
import { Alert } from "./alert";
import { getFortaApiHeaders, getFortaApiURL } from "./utils";
import { getFortaApiHeaders, getFortaApiURL, isPrivateFindings } from "./utils";
import { Finding } from "./finding";

export type SendAlerts = (
input: SendAlertsInput[] | SendAlertsInput
) => Promise<SendAlertsResponse[]>;
export const sendAlerts: SendAlerts = async (
input: SendAlertsInput[] | SendAlertsInput
): Promise<SendAlertsResponse[]> => {
if (!Array.isArray(input)) {
input = [input];
}

const response: RawGraphqlSendAlertsResponse = await axios.post(
getFortaApiURL(),
getMutationFromInput(input),
getFortaApiHeaders()
);

if (response.data && response.data.errors)
throw Error(JSON.stringify(response.data.errors));

return response.data.data.sendAlerts.alerts;
};

export interface SendAlertsInput {
botId: string;
finding: Finding;
}

export interface RawGraphqlSendAlertsResponse {
data: {
data: {
sendAlerts: {
alerts: SendAlertsResponse[];
};
};
errors: any;
};
}

export interface SendAlertsResponse {
alertHash?: string;
error?: SendAlertError;
}

export interface SendAlertError {
code: string;
message: string;
}

export type GetAlerts = (query: AlertQueryOptions) => Promise<AlertsResponse>;
export const getAlerts: GetAlerts = async (
Expand All @@ -12,7 +61,8 @@ export const getAlerts: GetAlerts = async (
getFortaApiHeaders()
);

if (response.data && response.data.errors) throw Error(JSON.stringify(response.data.errors));
if (response.data && response.data.errors)
throw Error(JSON.stringify(response.data.errors));

const pageInfo = response.data.data.alerts.pageInfo;
const alerts: Alert[] = [];
Expand Down Expand Up @@ -229,3 +279,66 @@ const getQueryFromAlertOptions = (options: AlertQueryOptions) => {
},
};
};

const getMutationFromInput = (inputs: SendAlertsInput[]) => {
return {
query: `
mutation SendAlerts(
$alerts: [AlertRequestInput!]!
) {
sendAlerts(alerts: $alerts) {
alerts {
alertHash
error {
code
message
}
}
}
}
`,
variables: {
alerts: inputs.map((input) => {
const finding = JSON.parse(input.finding.toString());
// convert enums to all caps to match graphql enums
finding.type = finding.type.toUpperCase();
finding.severity = finding.severity.toUpperCase();
for (const label of finding.labels) {
label.entityType = label.entityType.toUpperCase();
}
// remove protocol field (not part of graphql schema)
delete finding["protocol"];
// remove any empty fields
for (const key of Object.keys(finding)) {
if (isEmptyValue(finding[key])) {
delete finding[key];
} else if (key === "labels") {
// if there are labels, remove empty fields from them too
for (const label of finding.labels) {
for (const labelKey of Object.keys(label)) {
if (isEmptyValue(label[labelKey])) {
delete label[labelKey];
}
}
}
}
}
// set private flag
finding.private = isPrivateFindings();

return {
botId: input.botId,
finding,
};
}),
},
};
};

function isEmptyValue(val: any): boolean {
if (val == null || val == undefined) return true;
if (Array.isArray(val)) return val.length == 0;
if (typeof val === "string") return val.length == 0;
if (typeof val === "object") return Object.keys(val).length == 0;
return false;
}
57 changes: 35 additions & 22 deletions sdk/finding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,21 @@ describe("finding", () => {
entity: "Address",
label: "Address",
confidence: 1,
}
};

const labelInput2 = {
entityType: EntityType.Transaction,
entity: "Transaction",
label: "Transaction",
confidence: 1,
}
};

const mockLabels = [Label.fromObject(labelInput1), Label.fromObject(labelInput2)];
const mockLabels = [
Label.fromObject(labelInput1),
Label.fromObject(labelInput2),
];

const timestamp = new Date();

const findingInput = {
name: "High Tether Transfer",
Expand All @@ -45,30 +50,38 @@ describe("finding", () => {
},
addresses: ["0x01", "0x02", "0x03"],
labels: mockLabels,
}
timestamp,
};

const finding = Finding.fromObject(findingInput);

it("should convert to string", () => {
const expectedJSONString = JSON.stringify({
name: "High Tether Transfer",
description: `High amount of USDT transferred: ${normalizedValue}`,
alertId: "FORTA-1",
protocol: "ethereum",
severity: FindingSeverity[findingInput.severity],
type: FindingType[findingInput.type],
metadata: {
to: mockTetherTransferEvent.args.to,
from: mockTetherTransferEvent.args.from,
const expectedJSONString = JSON.stringify(
{
name: "High Tether Transfer",
description: `High amount of USDT transferred: ${normalizedValue}`,
alertId: "FORTA-1",
protocol: "ethereum",
severity: FindingSeverity[findingInput.severity],
type: FindingType[findingInput.type],
metadata: {
to: mockTetherTransferEvent.args.to,
from: mockTetherTransferEvent.args.from,
},
addresses: ["0x01", "0x02", "0x03"],
labels: findingInput.labels.map((l) => {
return {
...l,
entityType: EntityType[l.entityType],
};
}),
uniqueKey: "",
source: {},
timestamp,
},
addresses: ["0x01", "0x02", "0x03"],
labels: findingInput.labels.map(l => {
return {
...l,
entityType: EntityType[l.entityType],
}
},), uniqueKey: "", source: {}
}, null, 2);
null,
2
);

expect(finding.toString()).toEqual(expectedJSONString);
});
Expand Down
34 changes: 33 additions & 1 deletion sdk/finding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,37 @@ type FindingSourceChain = {
chainId: number
}

type FindingSourceBlock = {
chainId: number
hash: string
number: number
}

type FindingSourceTransaction = {
chainId: number
hash: string
}

type FindingSourceUrls = {
url: string
}

type FindingSourceAlerts = {
id: string
}

type FindingSourceCustom = {
name: string
value: string
}

type FindingSource = {
chains?: FindingSourceChain[]
blocks?: FindingSourceBlock[]
transactions?: FindingSourceTransaction[]
urls?: FindingSourceUrls[]
alerts?: FindingSourceAlerts[]
customSources?: FindingSourceCustom[]
}

type FindingInput = {
Expand All @@ -39,6 +68,7 @@ type FindingInput = {
labels?: Label[],
uniqueKey?: string,
source?: FindingSource
timestamp?: Date
}

export class Finding {
Expand All @@ -54,6 +84,7 @@ export class Finding {
readonly labels: Label[],
readonly uniqueKey: string,
readonly source: FindingSource,
readonly timestamp: Date
) {}

toString() {
Expand All @@ -77,6 +108,7 @@ export class Finding {
protocol = 'ethereum',
severity,
type,
timestamp = new Date(),
metadata = {},
addresses = [],
labels = [],
Expand All @@ -93,6 +125,6 @@ export class Finding {

labels = labels.map(l => l instanceof Label ? l : Label.fromObject(l))

return new Finding(name, description, alertId, protocol, severity, type, metadata, addresses, labels, uniqueKey, source)
return new Finding(name, description, alertId, protocol, severity, type, metadata, addresses, labels, uniqueKey, source, timestamp)
}
}
Loading

0 comments on commit 4e4c71a

Please sign in to comment.