diff --git a/src/helper/test-helper.ts b/src/helper/test-helper.ts index 7bc18c9..e36378f 100644 --- a/src/helper/test-helper.ts +++ b/src/helper/test-helper.ts @@ -139,6 +139,24 @@ function backendClientMaker(network: NetworkNames) { error: null, }); } + case query.getTokenBalanceSub( + "CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ", + tokenBalanceLedgerKey + ): { + return Promise.resolve({ + data: { + allEntryUpdates: { + nodes: [ + { + contractId: + "CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ", + }, + ], + }, + }, + error: null, + }); + } case query.getCurrentDataAccountBalances( pubKey, tokenBalanceLedgerKey, @@ -170,6 +188,18 @@ function backendClientMaker(network: NetworkNames) { error: null, }); } + case query.getCurrentDataAccountBalances(pubKey, tokenBalanceLedgerKey, [ + "CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP", + "CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG", + "CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ", + ]): { + return Promise.resolve({ + data: queryMockResponse[ + "query.getAccountBalancesCurrentDataWithThreeContracts" + ], + error: null, + }); + } case query.getAccountObject(pubKey): { return Promise.resolve({ data: queryMockResponse["query.getAccountObject"], @@ -307,6 +337,51 @@ const queryMockResponse = { }, ], }, + "query.getAccountBalancesCurrentDataWithThreeContracts": { + trustlinesByPublicKey: [ + { + balance: 100019646386, + asset: "AAAAAUJMTkQAAAAAJgXM07IdPwaDCLLNw46HAu0Jy3Az9GJKesWnsk57zF4=", + limit: 1, + accountId: pubKey, + }, + ], + accountByPublicKey: { + accountId: pubKey, + nativeBalance: "10", + numSubEntries: "1", + numSponsored: "1", + numSponsoring: "1", + sellingLiabilities: "1000000", + }, + CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP: [ + { + contractId: "CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP", + keyXdr: + "AAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAAAAAAAAIzohH0YeJGhVUA7vwkk2SzLZ8oNA2zaMGfxtpyxEtys", + valXdr: contractDataEntryValXdr, + durability: 1, + }, + ], + CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG: [ + { + contractId: "CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG", + keyXdr: + "AAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAAAAAAAAIzohH0YeJGhVUA7vwkk2SzLZ8oNA2zaMGfxtpyxEtys", + valXdr: contractDataEntryValXdr, + durability: 1, + }, + ], + CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ: [ + { + contractId: "CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ", + keyXdr: + "AAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAAAAAAAAIzohH0YeJGhVUA7vwkk2SzLZ8oNA2zaMGfxtpyxEtys", + valXdr: contractDataEntryValXdr, + durability: 1, + }, + ], + }, "query.getAccountObject": { accountObjectByPublicKey: { nodes: [ @@ -350,6 +425,19 @@ const queryMockResponse = { }, ], }, + CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ: { + nodes: [ + { + contractId: + "CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ", + keyXdr: tokenBalanceLedgerKey, + valueXdr, + ledgerTimestamp: "timestamp", + ledger: "1", + entryDurability: "persistent", + }, + ], + }, balanceByPublicKey: { nodes: [], }, diff --git a/src/route/index.test.ts b/src/route/index.test.ts index cbdb96f..24ff7ce 100644 --- a/src/route/index.test.ts +++ b/src/route/index.test.ts @@ -1,3 +1,4 @@ +import "@blockaid/client"; import { getDevServer, queryMockResponse, @@ -7,6 +8,28 @@ import { import { transformAccountHistory } from "../service/mercury/helpers/transformers"; import { query } from "../service/mercury/queries"; +jest.mock("@blockaid/client", () => { + return class Blockaid { + token = { + scan: (asset: { address: string; chain: string }) => { + if ( + asset.address === + "BLND-GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56" + ) { + return Promise.resolve({ malicious_score: 1 }); + } + if ( + asset.address === + "TST-CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ" + ) { + throw Error("ERROR"); + } + return Promise.resolve({ malicious_score: 0 }); + }, + }; + }; +}); + describe("API routes", () => { describe("/account-history/:pubKey", () => { it("can fetch an account history for a pub key", async () => { @@ -123,5 +146,93 @@ describe("API routes", () => { register.clear(); await server.close(); }); + + it("adds scanned status on Pubnet", async () => { + const contractIds = [ + "CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP", + "CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG", + ]; + const server = await getDevServer(); + const url = new URL( + `http://localhost:${ + (server?.server?.address() as any).port + }/api/v1/account-balances/${pubKey}` + ); + url.searchParams.append("network", "PUBLIC"); + for (const id of contractIds) { + url.searchParams.append("contract_ids", id); + } + const response = await fetch(url.href); + const data = await response.json(); + + expect(response.status).toEqual(200); + expect( + data.balances[ + "BLND:GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56" + ].isMalicious + ).toEqual(true); + expect( + data.balances[ + "TST:CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP" + ].isMalicious + ).toEqual(false); + register.clear(); + await server.close(); + }); + it("doesn't check scanned status on Testnet", async () => { + const contractIds = [ + "CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP", + "CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG", + ]; + const server = await getDevServer(); + const url = new URL( + `http://localhost:${ + (server?.server?.address() as any).port + }/api/v1/account-balances/${pubKey}` + ); + url.searchParams.append("network", "TESTNET"); + for (const id of contractIds) { + url.searchParams.append("contract_ids", id); + } + const response = await fetch(url.href); + const data = await response.json(); + + expect(response.status).toEqual(200); + expect( + data.balances[ + "BLND:GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56" + ].isMalicious + ).toEqual(false); + register.clear(); + await server.close(); + }); + it("defaults to not malicious on scan status error", async () => { + const contractIds = [ + "CCWAMYJME4H5CKG7OLXGC2T4M6FL52XCZ3OQOAV6LL3GLA4RO4WH3ASP", + "CBGTG7XFRY3L6OKAUTR6KGDKUXUQBX3YDJ3QFDYTGVMOM7VV4O7NCODG", + "CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ", + ]; + const server = await getDevServer(); + const url = new URL( + `http://localhost:${ + (server?.server?.address() as any).port + }/api/v1/account-balances/${pubKey}` + ); + url.searchParams.append("network", "PUBLIC"); + for (const id of contractIds) { + url.searchParams.append("contract_ids", id); + } + const response = await fetch(url.href); + const data = await response.json(); + + expect(response.status).toEqual(200); + expect( + data.balances[ + "TST:CDP3XWJ4ZN222LKYBMWIY3GYXZYX3KA6WVNDS6V7WKXSYWLAEMYW7DTZ" + ].isMalicious + ).toEqual(false); + register.clear(); + await server.close(); + }); }); }); diff --git a/src/route/index.ts b/src/route/index.ts index 19067af..bee8e0c 100644 --- a/src/route/index.ts +++ b/src/route/index.ts @@ -11,6 +11,7 @@ import * as StellarSdk from "stellar-sdk"; import { MercuryClient } from "../service/mercury"; import { BlockAidService } from "../service/blockaid"; +import { addScannedStatus } from "../service/blockaid/helpers/addScanResults"; import { ajv } from "./validators"; import { isContractId, @@ -300,6 +301,21 @@ export async function initApiServer( useMercury ); + try { + data.balances = await addScannedStatus( + data.balances, + blockAidService, + network, + logger + ); + } catch (e) { + data.balances = data.balances.map((bal: {}) => ({ + ...bal, + isMalicious: false, + })); + logger.error(e); + } + reply.code(200).send(data); } catch (error) { reply.code(500).send(ERROR.SERVER_ERROR); diff --git a/src/service/blockaid/helpers/addScanResults.ts b/src/service/blockaid/helpers/addScanResults.ts new file mode 100644 index 0000000..8d38f05 --- /dev/null +++ b/src/service/blockaid/helpers/addScanResults.ts @@ -0,0 +1,37 @@ +import { Logger } from "pino"; +import { BlockAidService } from ".."; +import { NetworkNames } from "../../../helper/validate"; + +export const addScannedStatus = async ( + balances: { [key: string]: {} }, + blockaidService: BlockAidService, + network: NetworkNames, + logger: Logger +) => { + const scannedBalances = {} as { [key: string]: { isMalicious: boolean } }; + const entries = Object.entries(balances); + + for (let i = 0; i < entries.length; i++) { + const [key, balanceInfo] = entries[i]; + let data; + if (key !== "native" && network === "PUBLIC") { + // we only scan non-native assets on the public network + try { + const splitKey = key.split(":"); + const blockaidKey = `${splitKey[0]}-${splitKey[1]}`; + const res = await blockaidService.scanAsset(blockaidKey); + + data = res.data; + } catch (e) { + logger.error(e); + } + } + + scannedBalances[key] = { + ...balanceInfo, + isMalicious: Boolean(Number(data?.malicious_score)) ?? false, + }; + } + + return scannedBalances; +};