Skip to content

Commit

Permalink
Merge branch 'main' into jl/selected-network-controller-domainProxyMap
Browse files Browse the repository at this point in the history
  • Loading branch information
adonesky1 authored Mar 26, 2024
2 parents 5d73e5c + 8f4c698 commit ccc3e5d
Show file tree
Hide file tree
Showing 16 changed files with 1,015 additions and 794 deletions.
287 changes: 123 additions & 164 deletions packages/assets-controllers/src/NftController.test.ts

Large diffs are not rendered by default.

322 changes: 94 additions & 228 deletions packages/assets-controllers/src/NftController.ts

Large diffs are not rendered by default.

575 changes: 395 additions & 180 deletions packages/assets-controllers/src/NftDetectionController.test.ts

Large diffs are not rendered by default.

304 changes: 240 additions & 64 deletions packages/assets-controllers/src/NftDetectionController.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { BaseConfig, BaseState } from '@metamask/base-controller';
import {
OPENSEA_PROXY_URL,
fetchWithErrorHandling,
toChecksumHexAddress,
ChainId,
timeoutFetch,
safelyExecute,
NFT_API_BASE_URL,
} from '@metamask/controller-utils';
import type {
NetworkClientId,
Expand All @@ -17,15 +15,11 @@ import { StaticIntervalPollingControllerV1 } from '@metamask/polling-controller'
import type { PreferencesState } from '@metamask/preferences-controller';
import type { Hex } from '@metamask/utils';

import { mapOpenSeaNftV2ToV1 } from './assetsUtil';
import { Source } from './constants';
import type { OpenSeaV2GetNftResponse } from './NftController';
import {
type NftController,
type NftState,
type NftMetadata,
type OpenSeaV2ListNftsResponse,
OpenSeaV2ChainIds,
} from './NftController';

const DEFAULT_INTERVAL = 180000;
Expand Down Expand Up @@ -100,6 +94,7 @@ export interface ApiNftContract {
collection: {
name: string | null;
image_url?: string | null;
tokenCount?: string | null;
};
}

Expand Down Expand Up @@ -154,6 +149,204 @@ export interface NftDetectionConfig extends BaseConfig {
selectedAddress: string;
}

export type ReservoirResponse = {
tokens: TokensResponse[];
continuation?: string;
};

export type TokensResponse = {
token: TokenResponse;
ownership: Ownership;
market?: Market;
blockaidResult?: Blockaid;
};

export enum BlockaidResultType {
Benign = 'Benign',
Spam = 'Spam',
Warning = 'Warning',
Malicious = 'Malicious',
}

export type Blockaid = {
contract: string;
chainId: number;
result_type: BlockaidResultType;
malicious_score: string;
attack_types: object;
};

export type Market = {
floorAsk?: FloorAsk;
topBid?: TopBid;
};

export type TokenResponse = {
chainId: number;
contract: string;
tokenId: string;
kind?: string;
name?: string;
image?: string;
imageSmall?: string;
imageLarge?: string;
metadata?: Metadata;
description?: string;
supply?: number;
remainingSupply?: number;
rarityScore?: number;
rarity?: number;
rarityRank?: number;
media?: string;
isFlagged?: boolean;
isSpam?: boolean;
isNsfw?: boolean;
metadataDisabled?: boolean;
lastFlagUpdate?: string;
lastFlagChange?: string;
collection?: Collection;
lastSale?: LastSale;
topBid?: TopBid;
lastAppraisalValue?: number;
attributes?: Attributes[];
};

export type TopBid = {
id?: string;
price?: Price;
source?: {
id?: string;
domain?: string;
name?: string;
icon?: string;
url?: string;
};
};

export type LastSale = {
saleId?: string;
token?: {
contract?: string;
tokenId?: string;
name?: string;
image?: string;
collection?: {
id?: string;
name?: string;
};
};
orderSource?: string;
orderSide?: 'ask' | 'bid';
orderKind?: string;
orderId?: string;
from?: string;
to?: string;
amount?: string;
fillSource?: string;
block?: number;
txHash?: string;
logIndex?: number;
batchIndex?: number;
timestamp?: number;
price?: Price;
washTradingScore?: number;
royaltyFeeBps?: number;
marketplaceFeeBps?: number;
paidFullRoyalty?: boolean;
feeBreakdown?: FeeBreakdown[];
isDeleted?: boolean;
createdAt?: string;
updatedAt?: string;
};

export type FeeBreakdown = {
kind?: string;
bps?: number;
recipient?: string;
source?: string;
rawAmount?: string;
};

export type Attributes = {
key?: string;
kind?: string;
value: string;
tokenCount?: number;
onSaleCount?: number;
floorAskPrice?: Price | null;
topBidValue?: number | null;
createdAt?: string;
};

export type Collection = {
id?: string;
name?: string;
slug?: string;
symbol?: string;
imageUrl?: string;
image?: string;
isSpam?: boolean;
isNsfw?: boolean;
creator?: string;
tokenCount?: string;
metadataDisabled?: boolean;
openseaVerificationStatus?: string;
floorAskPrice?: Price;
royaltiesBps?: number;
royalties?: Royalties[];
};

export type Royalties = {
bps?: number;
recipient?: string;
};

export type Ownership = {
tokenCount?: string;
onSaleCount?: string;
floorAsk?: FloorAsk;
acquiredAt?: string;
};

export type FloorAsk = {
id?: string;
price?: Price;
maker?: string;
kind?: string;
validFrom?: number;
validUntil?: number;
source?: Source;
rawData?: Metadata;
isNativeOffChainCancellable?: boolean;
};

export type Price = {
currency?: {
contract?: string;
name?: string;
symbol?: string;
decimals?: number;
chainId?: number;
};
amount?: {
raw?: string;
decimal?: number;
usd?: number;
native?: number;
};
netAmount?: {
raw?: string;
decimal?: number;
usd?: number;
native?: number;
};
};

export type Metadata = {
imageOriginal?: string;
tokenURI?: string;
};

/**
* Controller that passively polls on a set interval for NFT auto detection
*/
Expand All @@ -170,53 +363,41 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1<
address: string;
next?: string;
}) {
return `${OPENSEA_PROXY_URL}/chain/${
OpenSeaV2ChainIds.ethereum
}/account/${address}/nfts?limit=200&next=${next ?? ''}`;
return `${NFT_API_BASE_URL}/users/${address}/tokens?chainIds=1&limit=200&includeTopBid=true&continuation=${
next ?? ''
}`;
}

private async getOwnerNfts(address: string) {
let nftApiResponse: OpenSeaV2ListNftsResponse;
let nfts: ApiNft[] = [];
let nftApiResponse: ReservoirResponse;
let nfts: TokensResponse[] = [];
let next;

do {
nftApiResponse = await fetchWithErrorHandling({
url: this.getOwnerNftApi({ address, next }),
options: {
headers: {
Version: '1',
},
},
timeout: 15000,
});

if (!nftApiResponse) {
return nfts;
}

const newNfts = await Promise.all(
nftApiResponse.nfts.map(async (nftV2) => {
const nftV1 = mapOpenSeaNftV2ToV1(nftV2);

// If the image hasn't been processed into OpenSea's CDN, the image_url will be null.
// Try fetching the NFT individually, which returns the original image url from metadata if available.
if (!nftV1.image_url && nftV2.metadata_url) {
const nftDetails: OpenSeaV2GetNftResponse | undefined =
await safelyExecute(() =>
timeoutFetch(
this.getNftApi({
contractAddress: nftV2.contract,
tokenId: nftV2.identifier,
}),
undefined,
1000,
).then((r) => r.json()),
);

nftV1.image_original_url = nftDetails?.nft?.image_url ?? null;
}
return nftV1;
}),
const newNfts = nftApiResponse.tokens.filter(
(elm) =>
elm.token.isSpam === false &&
(elm.blockaidResult?.result_type
? elm.blockaidResult?.result_type === BlockaidResultType.Benign
: true),
);

nfts = [...nfts, ...newNfts];
} while ((next = nftApiResponse.next));
} while ((next = nftApiResponse.continuation));

return nfts;
}
Expand Down Expand Up @@ -401,24 +582,23 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1<
}

const apiNfts = await this.getOwnerNfts(userAddress);
const addNftPromises = apiNfts.map(async (nft: ApiNft) => {
const addNftPromises = apiNfts.map(async (nft) => {
const {
token_id,
num_sales,
background_color,
image_url,
image_preview_url,
image_thumbnail_url,
image_original_url,
animation_url,
animation_original_url,
tokenId: token_id,
contract,
kind,
image: image_url,
imageSmall: image_thumbnail_url,
metadata: { imageOriginal: image_original_url } = {},
name,
description,
external_link,
creator,
asset_contract: { address, schema_name },
last_sale,
} = nft;
attributes,
topBid,
lastSale,
rarityRank,
rarityScore,
collection,
} = nft.token;

let ignored;
/* istanbul ignore else */
Expand All @@ -427,7 +607,7 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1<
ignored = ignoredNfts.find((c) => {
/* istanbul ignore next */
return (
c.address === toChecksumHexAddress(address) &&
c.address === toChecksumHexAddress(contract) &&
c.tokenId === token_id
);
});
Expand All @@ -439,24 +619,20 @@ export class NftDetectionController extends StaticIntervalPollingControllerV1<
const nftMetadata: NftMetadata = Object.assign(
{},
{ name },
creator && { creator },
description && { description },
image_url && { image: image_url },
num_sales && { numberOfSales: num_sales },
background_color && { backgroundColor: background_color },
image_preview_url && { imagePreview: image_preview_url },
image_thumbnail_url && { imageThumbnail: image_thumbnail_url },
image_original_url && { imageOriginal: image_original_url },
animation_url && { animation: animation_url },
animation_original_url && {
animationOriginal: animation_original_url,
},
schema_name && { standard: schema_name },
external_link && { externalLink: external_link },
last_sale && { lastSale: last_sale },
kind && { standard: kind.toUpperCase() },
lastSale && { lastSale },
attributes && { attributes },
topBid && { topBid },
rarityRank && { rarityRank },
rarityScore && { rarityScore },
collection && { collection },
);

await this.addNft(address, token_id, {
await this.addNft(contract, token_id, {
nftMetadata,
userAddress,
source: Source.Detected,
Expand Down
Loading

0 comments on commit ccc3e5d

Please sign in to comment.