diff --git a/.changeset/purple-bats-march.md b/.changeset/purple-bats-march.md new file mode 100644 index 00000000000..6b92c289bb2 --- /dev/null +++ b/.changeset/purple-bats-march.md @@ -0,0 +1,34 @@ +--- +"thirdweb": patch +--- + +Use insight for erc821/getNFT, erc721/getNFTs and erc721/getOwnedNFTs + +Standard ERC721 getNFT, getNFTs and getOwnedNFTs now use insight, our in house indexer by default. If indexer is not availbale, will fallback to RPC. + +You can also use the indexer directly using the Insight API: + +for an entire collection + +```ts +import { Insight } from "thirdweb"; + +const events = await Insight.getContractNFTs({ + client, + chains: [sepolia], + contractAddress: "0x1234567890123456789012345678901234567890", +}); +``` + +or for a single NFT + +```ts +import { Insight } from "thirdweb"; + +const events = await Insight.getNFT({ + client, + chains: [sepolia], + contractAddress: "0x1234567890123456789012345678901234567890", + tokenId: 1n, +}); +``` diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 52c85fe81f3..49f7e4967d1 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -16,6 +16,7 @@ env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TW_SECRET_KEY: ${{ secrets.TW_SECRET_KEY }} + TW_CLIENT_ID: ${{ secrets.TW_CLIENT_ID }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} jobs: diff --git a/apps/dashboard/src/@/actions/getWalletNFTs.ts b/apps/dashboard/src/@/actions/getWalletNFTs.ts index 6c3dff1ffcb..93e78575804 100644 --- a/apps/dashboard/src/@/actions/getWalletNFTs.ts +++ b/apps/dashboard/src/@/actions/getWalletNFTs.ts @@ -81,6 +81,7 @@ export async function getWalletNFTs(params: { const result = await transformMoralisResponseToNFT( await parsedResponse, owner, + chainId, ); return { result }; @@ -194,6 +195,8 @@ async function getWalletNFTsFromInsight(params: { tokenURI: nft.metadata_url, type: nft.token_type === "erc721" ? "ERC721" : "ERC1155", supply: nft.balance, + tokenAddress: nft.contract.address, + chainId: nft.contract.chain_id, }; return walletNFT; diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx index c619197e6c5..e246889fb0a 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/(marketplace)/components/list-form.tsx @@ -205,6 +205,8 @@ export const CreateListingsForm: React.FC = ({ owner: nft.owner, type: "ERC721", tokenURI: nft.tokenURI, + chainId: nft.chainId, + tokenAddress: nft.tokenAddress, }; } return { @@ -216,6 +218,8 @@ export const CreateListingsForm: React.FC = ({ owner: nft.owner, type: "ERC1155", tokenURI: nft.tokenURI, + chainId: nft.chainId, + tokenAddress: nft.tokenAddress, }; }) as WalletNFT[]; }, [ownedNFTs, form]); diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/NFTCards.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/NFTCards.tsx index cee6608db96..71b3a838451 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/NFTCards.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_components/NFTCards.tsx @@ -21,6 +21,7 @@ const dummyMetadata: (idx: number) => NFTWithContract = (idx) => ({ owner: `0x_fake_${idx}`, type: "ERC721", supply: 1n, + tokenAddress: ZERO_ADDRESS, }); interface NFTCardsProps { diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/account/components/nfts-owned.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/account/components/nfts-owned.tsx index 5e758802e33..3c67dd58d29 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/account/components/nfts-owned.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/account/components/nfts-owned.tsx @@ -33,6 +33,7 @@ export const NftsOwned: React.FC = ({ type: nft.type, contractAddress: nft.contractAddress, chainId: contract.chain.id, + tokenAddress: nft.tokenAddress, }))} allNfts isPending={isWalletNFTsLoading} diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/MarketplaceDetails.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/MarketplaceDetails.tsx index 211781c8663..64b1ae593b3 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/MarketplaceDetails.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/MarketplaceDetails.tsx @@ -7,7 +7,7 @@ import { SkeletonContainer } from "@/components/ui/skeleton"; import { TrackedLinkTW } from "@/components/ui/tracked-link"; import { ArrowRightIcon } from "lucide-react"; import { useMemo } from "react"; -import type { ThirdwebContract } from "thirdweb"; +import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb"; import { type DirectListing, type EnglishAuction, @@ -237,6 +237,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({ supply: BigInt(1), tokenURI: "", type: "ERC721", + tokenAddress: ZERO_ADDRESS, + chainId: 1, }, currencyValuePerToken: { decimals: 18, @@ -244,6 +246,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({ name: "Ether", symbol: "ETH", value: 0n, + tokenAddress: ZERO_ADDRESS, + chainId: 1, }, creatorAddress: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", type: "direct-listing", @@ -253,6 +257,8 @@ const dummyMetadata: (idx: number) => ListingData = (idx) => ({ value: 0n, displayValue: "0.0", decimals: 18, + tokenAddress: ZERO_ADDRESS, + chainId: 1, }, }); diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/supply.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/supply.tsx index 741988456f1..39997d601fe 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/supply.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/supply.tsx @@ -46,8 +46,15 @@ export const TokenDetailsCard: React.FC = ({ tokenSupplyQuery.data, tokenMetadataQuery.data.decimals, ), + tokenAddress: contract.address, + chainId: contract.chain.id, }; - }, [tokenMetadataQuery.data, tokenSupplyQuery.data]); + }, [ + tokenMetadataQuery.data, + tokenSupplyQuery.data, + contract.address, + contract.chain.id, + ]); return ( { return ( await Promise.all( @@ -47,6 +48,8 @@ export async function transformMoralisResponseToNFT( tokenURI: moralisNft.token_uri, supply: moralisNft.amount || "1", type: moralisNft.contract_type, + tokenAddress: moralisNft.token_address, + chainId, } as WalletNFT; } catch { return undefined as unknown as WalletNFT; diff --git a/packages/thirdweb/src/chains/utils.ts b/packages/thirdweb/src/chains/utils.ts index 67bd56f353c..dfb4fe9372a 100644 --- a/packages/thirdweb/src/chains/utils.ts +++ b/packages/thirdweb/src/chains/utils.ts @@ -296,8 +296,9 @@ export function getChainMetadata(chain: Chain): Promise { `https://api.thirdweb.com/v1/chains/${chainId}`, ); if (!res.ok) { - res.body?.cancel(); - throw new Error(`Failed to fetch chain data for chainId ${chainId}`); + throw new Error( + `Failed to fetch chain data for chainId ${chainId}. ${res.status} ${res.statusText}`, + ); } const response = (await res.json()) as FetchChainResponse; @@ -356,8 +357,9 @@ export function getChainServices(chain: Chain): Promise { `https://api.thirdweb.com/v1/chains/${chainId}/services`, ); if (!res.ok) { - res.body?.cancel(); - throw new Error(`Failed to fetch services for chainId ${chainId}`); + throw new Error( + `Failed to fetch services for chainId ${chainId}. ${res.status} ${res.statusText}`, + ); } const response = (await res.json()) as FetchChainServiceResponse; diff --git a/packages/thirdweb/src/event/actions/get-events.ts b/packages/thirdweb/src/event/actions/get-events.ts index ea2dd94ff28..3491c4d4f1d 100644 --- a/packages/thirdweb/src/event/actions/get-events.ts +++ b/packages/thirdweb/src/event/actions/get-events.ts @@ -5,7 +5,6 @@ import type { ExtractAbiEventNames, } from "abitype"; import { type Log, formatLog } from "viem"; -import { getChainServices } from "../../chains/utils.js"; import { resolveContractAbi } from "../../contract/actions/resolve-abi.js"; import type { ThirdwebContract } from "../../contract/contract.js"; import { getContractEvents as getContractEventsInsight } from "../../insight/get-events.js"; @@ -197,7 +196,7 @@ export async function getContractEvents< ), ); } catch (e) { - console.warn("Error fetching from insight", e); + console.warn("Error fetching from insight, falling back to rpc", e); // fetch from rpc logs = await Promise.all( logsParams.map((ethLogParams) => eth_getLogs(rpcRequest, ethLogParams)), @@ -225,17 +224,6 @@ async function getLogsFromInsight(options: { }): Promise { const { params, contract } = options; - const chainServices = await getChainServices(contract.chain); - const insightEnabled = chainServices.some( - (c) => c.service === "insight" && c.enabled, - ); - - if (!insightEnabled) { - throw new Error( - `Insight is not available for chainId ${contract.chain.id}`, - ); - } - const fromBlock = typeof params.fromBlock === "bigint" ? Number(params.fromBlock) : undefined; diff --git a/packages/thirdweb/src/extensions/erc1155/read/getNFT.test.ts b/packages/thirdweb/src/extensions/erc1155/read/getNFT.test.ts index 7862469a843..55039f1ec85 100644 --- a/packages/thirdweb/src/extensions/erc1155/read/getNFT.test.ts +++ b/packages/thirdweb/src/extensions/erc1155/read/getNFT.test.ts @@ -11,6 +11,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc1155.getNFT", () => { }); expect(nft).toMatchInlineSnapshot(` { + "chainId": 1, "id": 2n, "metadata": { "animation_url": "ipfs://QmYoM63qaumQznBRx38tQjkY4ewbymeFb2KWBhkfMqNHax/3.mp4", @@ -36,6 +37,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc1155.getNFT", () => { }, "owner": null, "supply": 2519n, + "tokenAddress": "0x42d3641255C946CC451474295d29D3505173F22A", "tokenURI": "ipfs://QmbMXdbnNUAuGRoY6c6G792c6T9utfaBGqRUaMaRUf52Cb/2", "type": "ERC1155", } diff --git a/packages/thirdweb/src/extensions/erc1155/read/getNFT.ts b/packages/thirdweb/src/extensions/erc1155/read/getNFT.ts index 38e34b0e61d..63f1d48d105 100644 --- a/packages/thirdweb/src/extensions/erc1155/read/getNFT.ts +++ b/packages/thirdweb/src/extensions/erc1155/read/getNFT.ts @@ -58,6 +58,8 @@ export async function getNFT( type: "ERC1155", owner: null, supply, + tokenAddress: options.contract.address, + chainId: options.contract.chain.id, }, ); } diff --git a/packages/thirdweb/src/extensions/erc20/drop20.test.ts b/packages/thirdweb/src/extensions/erc20/drop20.test.ts index 5608b92acd4..e8fc5fd52fa 100644 --- a/packages/thirdweb/src/extensions/erc20/drop20.test.ts +++ b/packages/thirdweb/src/extensions/erc20/drop20.test.ts @@ -59,17 +59,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( }, 60_000); it("should allow to claim tokens", async () => { - await expect( - getBalance({ contract, address: TEST_ACCOUNT_A.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "0", - "name": "Test DropERC20", - "symbol": "", - "value": 0n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_A.address })) + .displayValue, + ).toBe("0"); await sendAndConfirmTransaction({ transaction: setClaimConditions({ contract, @@ -98,31 +91,17 @@ describe.runIf(process.env.TW_SECRET_KEY)( transaction: claimTx, account: TEST_ACCOUNT_A, }); - await expect( - getBalance({ contract, address: TEST_ACCOUNT_A.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "1", - "name": "Test DropERC20", - "symbol": "", - "value": 1000000000000000000n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_A.address })) + .displayValue, + ).toBe("1"); }); it("should allow to claim tokens with value", async () => { - await expect( - getBalance({ contract, address: TEST_ACCOUNT_C.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "0", - "name": "Test DropERC20", - "symbol": "", - "value": 0n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_C.address })) + .displayValue, + ).toBe("0"); // set cc with price await sendAndConfirmTransaction({ transaction: setClaimConditions({ @@ -150,17 +129,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( transaction: claimTx, account: TEST_ACCOUNT_C, }); - await expect( - getBalance({ contract, address: TEST_ACCOUNT_C.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "2", - "name": "Test DropERC20", - "symbol": "", - "value": 2000000000000000000n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_C.address })) + .displayValue, + ).toBe("2"); }); describe("Allowlists", () => { @@ -181,17 +153,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( account: TEST_ACCOUNT_A, }); - await expect( - getBalance({ contract, address: TEST_ACCOUNT_B.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "0", - "name": "Test DropERC20", - "symbol": "", - "value": 0n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_B.address })) + .displayValue, + ).toBe("0"); expect( await canClaim({ @@ -228,17 +193,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( }), }); - await expect( - getBalance({ contract, address: TEST_ACCOUNT_B.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "1", - "name": "Test DropERC20", - "symbol": "", - "value": 1000000000000000000n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_B.address })) + .displayValue, + ).toBe("1"); await expect( sendAndConfirmTransaction({ @@ -274,17 +232,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( account: TEST_ACCOUNT_A, }); - await expect( - getBalance({ contract, address: TEST_ACCOUNT_A.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "1", - "name": "Test DropERC20", - "symbol": "", - "value": 1000000000000000000n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_A.address })) + .displayValue, + ).toBe("1"); // we try to claim an extra `2` tokens // this should faile bcause the max claimable is `3` and we have previously already claimed 2 tokens (one for ourselves, one for the other wallet) @@ -318,17 +269,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( }), }); - await expect( - getBalance({ contract, address: TEST_ACCOUNT_A.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "2", - "name": "Test DropERC20", - "symbol": "", - "value": 2000000000000000000n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_A.address })) + .displayValue, + ).toBe("2"); }); }); @@ -353,17 +297,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( account: TEST_ACCOUNT_A, }); - await expect( - getBalance({ contract, address: TEST_ACCOUNT_A.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "2", - "name": "Test DropERC20", - "symbol": "", - "value": 2000000000000000000n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_A.address })) + .displayValue, + ).toBe("2"); await sendAndConfirmTransaction({ account: TEST_ACCOUNT_A, @@ -374,17 +311,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( }), }); - await expect( - getBalance({ contract, address: TEST_ACCOUNT_A.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "3", - "name": "Test DropERC20", - "symbol": "", - "value": 3000000000000000000n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_A.address })) + .displayValue, + ).toBe("3"); }); it("should be able to retrieve multiple phases", async () => { @@ -436,17 +366,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( account: TEST_ACCOUNT_D, }); // check that the account has claimed one token - await expect( - getBalance({ contract, address: TEST_ACCOUNT_D.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "0.000000000000000001", - "name": "Test DropERC20", - "symbol": "", - "value": 1n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_D.address })) + .displayValue, + ).toBe("0.000000000000000001"); // attempt to claim another token (this should fail) await expect( @@ -482,17 +405,10 @@ describe.runIf(process.env.TW_SECRET_KEY)( account: TEST_ACCOUNT_D, }); // check that the account has claimed two tokens - await expect( - getBalance({ contract, address: TEST_ACCOUNT_D.address }), - ).resolves.toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "0.000000000000000002", - "name": "Test DropERC20", - "symbol": "", - "value": 2n, - } - `); + expect( + (await getBalance({ contract, address: TEST_ACCOUNT_D.address })) + .displayValue, + ).toBe("0.000000000000000002"); }); }, ); diff --git a/packages/thirdweb/src/extensions/erc20/read/getBalance.test.ts b/packages/thirdweb/src/extensions/erc20/read/getBalance.test.ts index cedfccbdbe6..805f360d608 100644 --- a/packages/thirdweb/src/extensions/erc20/read/getBalance.test.ts +++ b/packages/thirdweb/src/extensions/erc20/read/getBalance.test.ts @@ -10,14 +10,10 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc20.getBalance", () => { contract: USDT_CONTRACT, address: VITALIK_WALLET, }); - expect(balance).toMatchInlineSnapshot(` - { - "decimals": 6, - "displayValue": "1544.900798", - "name": "Tether USD", - "symbol": "USDT", - "value": 1544900798n, - } - `); + expect(balance.displayValue).toBe("1544.900798"); + expect(balance.name).toBe("Tether USD"); + expect(balance.symbol).toBe("USDT"); + expect(balance.value).toBe(1544900798n); + expect(balance.decimals).toBe(6); }); }); diff --git a/packages/thirdweb/src/extensions/erc20/read/getBalance.ts b/packages/thirdweb/src/extensions/erc20/read/getBalance.ts index 9c126d0f4d8..a35e26c54d5 100644 --- a/packages/thirdweb/src/extensions/erc20/read/getBalance.ts +++ b/packages/thirdweb/src/extensions/erc20/read/getBalance.ts @@ -23,6 +23,8 @@ export type GetBalanceResult = { displayValue: string; symbol: string; name: string; + tokenAddress: string; + chainId: number; }; /** @@ -48,5 +50,7 @@ export async function getBalance( ...currencyMetadata, value: balanceWei, displayValue: toTokens(balanceWei, currencyMetadata.decimals), + tokenAddress: options.contract.address, + chainId: options.contract.chain.id, }; } diff --git a/packages/thirdweb/src/extensions/erc721/read/getNFT.test.ts b/packages/thirdweb/src/extensions/erc721/read/getNFT.test.ts index 3574221e162..621a0203e40 100644 --- a/packages/thirdweb/src/extensions/erc721/read/getNFT.test.ts +++ b/packages/thirdweb/src/extensions/erc721/read/getNFT.test.ts @@ -3,14 +3,118 @@ import { DOODLES_CONTRACT } from "~test/test-contracts.js"; import { getNFT } from "./getNFT.js"; describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFT", () => { + it("without owner using indexer", async () => { + const nft = await getNFT({ + contract: DOODLES_CONTRACT, + tokenId: 1n, + includeOwner: false, + }); + expect(nft.metadata.name).toBe("Doodle #1"); + // TODO (insight): re-enable once insight fixes the client id caching issue + // expect(nft).toMatchInlineSnapshot(` + // { + // "chainId": 1, + // "id": 1n, + // "metadata": { + // "attributes": [ + // { + // "trait_type": "face", + // "value": "holographic beard", + // }, + // { + // "trait_type": "hair", + // "value": "white bucket cap", + // }, + // { + // "trait_type": "body", + // "value": "purple sweater with satchel", + // }, + // { + // "trait_type": "background", + // "value": "grey", + // }, + // { + // "trait_type": "head", + // "value": "gradient 2", + // }, + // ], + // "description": "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + // "image": "https://${clientId}.ipfscdn.io/ipfs/QmTDxnzcvj2p3xBrKcGv1wxoyhAn2yzCQnZZ9LmFjReuH9", + // "image_url": "https://${clientId}.ipfscdn.io/ipfs/QmTDxnzcvj2p3xBrKcGv1wxoyhAn2yzCQnZZ9LmFjReuH9", + // "name": "Doodle #1", + // "uri": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", + // }, + // "owner": null, + // "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + // "tokenURI": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", + // "type": "ERC721", + // } + // `); + }); + + it("with owner using indexer", async () => { + const nft = await getNFT({ + contract: { ...DOODLES_CONTRACT }, + tokenId: 1n, + includeOwner: true, + }); + expect(nft.metadata.name).toBe("Doodle #1"); + expect(nft.owner).toBe("0xbe9936fcfc50666f5425fde4a9decc59cef73b24"); + // TODO (insight): re-enable once insight fixes the client id caching issue + // expect(nft).toMatchInlineSnapshot(` + // { + // "chainId": 1, + // "id": 1n, + // "metadata": { + // "attributes": [ + // { + // "trait_type": "face", + // "value": "holographic beard", + // }, + // { + // "trait_type": "hair", + // "value": "white bucket cap", + // }, + // { + // "trait_type": "body", + // "value": "purple sweater with satchel", + // }, + // { + // "trait_type": "background", + // "value": "grey", + // }, + // { + // "trait_type": "head", + // "value": "gradient 2", + // }, + // ], + // "description": "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + // "image": "https://${clientId}.ipfscdn.io/ipfs/QmTDxnzcvj2p3xBrKcGv1wxoyhAn2yzCQnZZ9LmFjReuH9", + // "image_url": "https://${clientId}.ipfscdn.io/ipfs/QmTDxnzcvj2p3xBrKcGv1wxoyhAn2yzCQnZZ9LmFjReuH9", + // "name": "Doodle #1", + // "owner_addresses": [ + // "0xbe9936fcfc50666f5425fde4a9decc59cef73b24", + // ], + // "uri": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", + // }, + // "owner": "0xbe9936fcfc50666f5425fde4a9decc59cef73b24", + // "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + // "tokenURI": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", + // "type": "ERC721", + // } + // `); + }); + it("without owner", async () => { const nft = await getNFT({ contract: { ...DOODLES_CONTRACT }, tokenId: 1n, includeOwner: false, + useIndexer: false, }); expect(nft).toMatchInlineSnapshot(` { + "chainId": 1, "id": 1n, "metadata": { "attributes": [ @@ -40,6 +144,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFT", () => { "name": "Doodle #1", }, "owner": null, + "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", "tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", "type": "ERC721", } @@ -51,9 +156,11 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFT", () => { contract: { ...DOODLES_CONTRACT }, tokenId: 1n, includeOwner: true, + useIndexer: false, }); expect(nft).toMatchInlineSnapshot(` { + "chainId": 1, "id": 1n, "metadata": { "attributes": [ @@ -83,6 +190,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFT", () => { "name": "Doodle #1", }, "owner": "0xbE9936FCFC50666f5425FDE4A9decC59cEF73b24", + "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", "tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", "type": "ERC721", } diff --git a/packages/thirdweb/src/extensions/erc721/read/getNFT.ts b/packages/thirdweb/src/extensions/erc721/read/getNFT.ts index 22bcb7231f7..b988ec86671 100644 --- a/packages/thirdweb/src/extensions/erc721/read/getNFT.ts +++ b/packages/thirdweb/src/extensions/erc721/read/getNFT.ts @@ -7,8 +7,8 @@ import { tokenURI, } from "../__generated__/IERC721A/read/tokenURI.js"; import { tokenByIndex } from "../__generated__/IERC721Enumerable/read/tokenByIndex.js"; - export { isTokenURISupported as isGetNFTSupported } from "../__generated__/IERC721A/read/tokenURI.js"; +import { getNFT as getNFTInsight } from "../../../insight/index.js"; /** * Parameters for getting an NFT. @@ -27,6 +27,11 @@ export type GetNFTParams = Prettify< * In this case, the provided tokenId will be considered as token-index and actual tokenId will be fetched from the contract. */ tokenByIndex?: boolean; + /** + * Whether to use the insight API to fetch the NFT. + * @default true + */ + useIndexer?: boolean; } >; @@ -58,6 +63,51 @@ export type GetNFTParams = Prettify< */ export async function getNFT( options: BaseTransactionOptions, +): Promise { + const { useIndexer = true } = options; + if (useIndexer) { + try { + return await getNFTFromInsight(options); + } catch { + return await getNFTFromRPC(options); + } + } + return await getNFTFromRPC(options); +} + +async function getNFTFromInsight( + options: BaseTransactionOptions, +): Promise { + const tokenId = options.tokenId; + const nft = await getNFTInsight({ + client: options.contract.client, + chain: options.contract.chain, + contractAddress: options.contract.address, + tokenId: options.tokenId, + includeOwners: options.includeOwner, + }); + if (!nft) { + return parseNFT( + { + id: tokenId, + type: "ERC721", + uri: "", + }, + { + tokenId, + tokenUri: "", + type: "ERC721", + owner: null, + tokenAddress: options.contract.address, + chainId: options.contract.chain.id, + }, + ); + } + return nft; +} + +async function getNFTFromRPC( + options: BaseTransactionOptions, ): Promise { let tokenId = options.tokenId; if (options.tokenByIndex) { @@ -90,6 +140,8 @@ export async function getNFT( tokenUri: "", type: "ERC721", owner, + tokenAddress: options.contract.address, + chainId: options.contract.chain.id, }, ); } @@ -109,6 +161,8 @@ export async function getNFT( tokenUri: uri, type: "ERC721", owner, + tokenAddress: options.contract.address, + chainId: options.contract.chain.id, }, ); } diff --git a/packages/thirdweb/src/extensions/erc721/read/getNFTs.test.ts b/packages/thirdweb/src/extensions/erc721/read/getNFTs.test.ts index 9c07a103b39..f85e59cc53d 100644 --- a/packages/thirdweb/src/extensions/erc721/read/getNFTs.test.ts +++ b/packages/thirdweb/src/extensions/erc721/read/getNFTs.test.ts @@ -6,16 +6,225 @@ import { import { getNFTs } from "./getNFTs.js"; describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => { - it("works for a contract with 0 indexed NFTs", async () => { + it("works for a contract with indexer", async () => { const nfts = await getNFTs({ contract: DOODLES_CONTRACT, count: 5, }); + expect(nfts.length).toBe(5); + // TODO (insight): re-enable once insight fixes the client id caching issue + // expect(nfts).toMatchInlineSnapshot(` + // [ + // { + // "chainId": 1, + // "id": 0n, + // "metadata": { + // "attributes": [ + // { + // "trait_type": "face", + // "value": "mustache", + // }, + // { + // "trait_type": "hair", + // "value": "purple long", + // }, + // { + // "trait_type": "body", + // "value": "blue and yellow jacket", + // }, + // { + // "trait_type": "background", + // "value": "green", + // }, + // { + // "trait_type": "head", + // "value": "tan", + // }, + // ], + // "description": "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + // "image": "https://${clientId}.ipfscdn.io/ipfs/QmUEfFfwAh4wyB5UfHCVPUxis4j4Q4kJXtm5x5p3g1fVUn", + // "image_url": "https://${clientId}.ipfscdn.io/ipfs/QmUEfFfwAh4wyB5UfHCVPUxis4j4Q4kJXtm5x5p3g1fVUn", + // "name": "Doodle #0", + // "uri": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/0", + // }, + // "owner": null, + // "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + // "tokenURI": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/0", + // "type": "ERC721", + // }, + // { + // "chainId": 1, + // "id": 1n, + // "metadata": { + // "attributes": [ + // { + // "trait_type": "face", + // "value": "holographic beard", + // }, + // { + // "trait_type": "hair", + // "value": "white bucket cap", + // }, + // { + // "trait_type": "body", + // "value": "purple sweater with satchel", + // }, + // { + // "trait_type": "background", + // "value": "grey", + // }, + // { + // "trait_type": "head", + // "value": "gradient 2", + // }, + // ], + // "description": "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + // "image": "https://${clientId}.ipfscdn.io/ipfs/QmTDxnzcvj2p3xBrKcGv1wxoyhAn2yzCQnZZ9LmFjReuH9", + // "image_url": "https://${clientId}.ipfscdn.io/ipfs/QmTDxnzcvj2p3xBrKcGv1wxoyhAn2yzCQnZZ9LmFjReuH9", + // "name": "Doodle #1", + // "uri": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", + // }, + // "owner": null, + // "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + // "tokenURI": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", + // "type": "ERC721", + // }, + // { + // "chainId": 1, + // "id": 2n, + // "metadata": { + // "attributes": [ + // { + // "trait_type": "face", + // "value": "designer glasses", + // }, + // { + // "trait_type": "hair", + // "value": "poopie", + // }, + // { + // "trait_type": "body", + // "value": "blue fleece", + // }, + // { + // "trait_type": "background", + // "value": "yellow", + // }, + // { + // "trait_type": "head", + // "value": "purple", + // }, + // ], + // "description": "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + // "image": "https://${clientId}.ipfscdn.io/ipfs/QmbvZ2hbF3nEq5r3ijMEiSGssAmJvtyFwiejTAGHv74LR5", + // "image_url": "https://${clientId}.ipfscdn.io/ipfs/QmbvZ2hbF3nEq5r3ijMEiSGssAmJvtyFwiejTAGHv74LR5", + // "name": "Doodle #2", + // "uri": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/2", + // }, + // "owner": null, + // "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + // "tokenURI": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/2", + // "type": "ERC721", + // }, + // { + // "chainId": 1, + // "id": 3n, + // "metadata": { + // "attributes": [ + // { + // "trait_type": "face", + // "value": "designer glasses", + // }, + // { + // "trait_type": "hair", + // "value": "holographic mohawk", + // }, + // { + // "trait_type": "body", + // "value": "pink fleece", + // }, + // { + // "trait_type": "background", + // "value": "gradient 1", + // }, + // { + // "trait_type": "head", + // "value": "pale", + // }, + // ], + // "description": "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + // "image": "https://${clientId}.ipfscdn.io/ipfs/QmVpwaCqLut3wqwB5KSQr2fGnbLuJt5e3LhNvzvcisewZB", + // "image_url": "https://${clientId}.ipfscdn.io/ipfs/QmVpwaCqLut3wqwB5KSQr2fGnbLuJt5e3LhNvzvcisewZB", + // "name": "Doodle #3", + // "uri": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/3", + // }, + // "owner": null, + // "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + // "tokenURI": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/3", + // "type": "ERC721", + // }, + // { + // "chainId": 1, + // "id": 4n, + // "metadata": { + // "attributes": [ + // { + // "trait_type": "face", + // "value": "happy", + // }, + // { + // "trait_type": "hair", + // "value": "purple long", + // }, + // { + // "trait_type": "body", + // "value": "spotted hoodie", + // }, + // { + // "trait_type": "background", + // "value": "gradient 2", + // }, + // { + // "trait_type": "head", + // "value": "purple", + // }, + // ], + // "description": "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", + // "image": "https://${clientId}.ipfscdn.io/ipfs/QmcyuFVLbfBmSeQ9ynu4dk67r97nB1abEekotuVuRGWedm", + // "image_url": "https://${clientId}.ipfscdn.io/ipfs/QmcyuFVLbfBmSeQ9ynu4dk67r97nB1abEekotuVuRGWedm", + // "name": "Doodle #4", + // "uri": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/4", + // }, + // "owner": null, + // "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + // "tokenURI": "https://${clientId}.ipfscdn.io/ipfs/QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/4", + // "type": "ERC721", + // }, + // ] + // `); + }); + + it("should throw error if totalSupply and nextTokenIdToMint are not supported", async () => { + await expect( + getNFTs({ contract: UNISWAPV3_FACTORY_CONTRACT, useIndexer: false }), + ).rejects.toThrowError( + "Contract requires either `nextTokenIdToMint` or `totalSupply` function available to determine the next token ID to mint", + ); + }); + + it("works for a contract with 0 indexed NFTs using RPC", async () => { + const nfts = await getNFTs({ + contract: DOODLES_CONTRACT, + count: 5, + useIndexer: false, + }); + expect(nfts.length).toBe(5); expect(nfts).toMatchInlineSnapshot(` [ { + "chainId": 1, "id": 0n, "metadata": { "attributes": [ @@ -45,10 +254,12 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => { "name": "Doodle #0", }, "owner": null, + "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", "tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/0", "type": "ERC721", }, { + "chainId": 1, "id": 1n, "metadata": { "attributes": [ @@ -78,10 +289,12 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => { "name": "Doodle #1", }, "owner": null, + "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", "tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/1", "type": "ERC721", }, { + "chainId": 1, "id": 2n, "metadata": { "attributes": [ @@ -111,10 +324,12 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => { "name": "Doodle #2", }, "owner": null, + "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", "tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/2", "type": "ERC721", }, { + "chainId": 1, "id": 3n, "metadata": { "attributes": [ @@ -144,10 +359,12 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => { "name": "Doodle #3", }, "owner": null, + "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", "tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/3", "type": "ERC721", }, { + "chainId": 1, "id": 4n, "metadata": { "attributes": [ @@ -177,22 +394,11 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getNFTs", () => { "name": "Doodle #4", }, "owner": null, + "tokenAddress": "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", "tokenURI": "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/4", "type": "ERC721", }, ] `); }); - - it.todo("works for a contract with `1` indexed NFTs", async () => { - // TODO find a contract that we can use that has "1 indexed" NFTs, then re-enable this test - }); - - it("should throw error if totalSupply and nextTokenIdToMint are not supported", async () => { - await expect( - getNFTs({ contract: UNISWAPV3_FACTORY_CONTRACT }), - ).rejects.toThrowError( - "Contract requires either `nextTokenIdToMint` or `totalSupply` function available to determine the next token ID to mint", - ); - }); }); diff --git a/packages/thirdweb/src/extensions/erc721/read/getNFTs.ts b/packages/thirdweb/src/extensions/erc721/read/getNFTs.ts index e25f44488d8..9c8c9fdc501 100644 --- a/packages/thirdweb/src/extensions/erc721/read/getNFTs.ts +++ b/packages/thirdweb/src/extensions/erc721/read/getNFTs.ts @@ -1,3 +1,4 @@ +import { getContractNFTs } from "../../../insight/index.js"; import type { BaseTransactionOptions } from "../../../transaction/types.js"; import { min } from "../../../utils/bigint.js"; import type { NFT } from "../../../utils/nft/parseNft.js"; @@ -41,6 +42,11 @@ export type GetNFTsParams = { * In this case, the provided tokenId will be considered as token-index and actual tokenId will be fetched from the contract. */ tokenByIndex?: boolean; + /** + * Whether to use the insight API to fetch the NFTs. + * @default true + */ + useIndexer?: boolean; }; /** @@ -61,6 +67,59 @@ export type GetNFTsParams = { */ export async function getNFTs( options: BaseTransactionOptions, +): Promise { + const { useIndexer = true } = options; + if (useIndexer) { + try { + return await getNFTsFromInsight(options); + } catch { + return await getNFTsFromRPC(options); + } + } + return await getNFTsFromRPC(options); +} + +/** + * Checks if the `getNFTs` method is supported by the given contract. + * @param availableSelectors An array of 4byte function selectors of the contract. You can get this in various ways, such as using "whatsabi" or if you have the ABI of the contract available you can use it to generate the selectors. + * @returns A boolean indicating if the `getNFTs` method is supported. + * @extension ERC721 + * @example + * ```ts + * import { isGetNFTsSupported } from "thirdweb/extensions/erc721"; + * + * const supported = isGetNFTsSupported(["0x..."]); + * ``` + */ +export function isGetNFTsSupported(availableSelectors: string[]) { + return ( + isGetNFTSupported(availableSelectors) && + (isTotalSupplySupported(availableSelectors) || + isNextTokenIdToMintSupported(availableSelectors)) + ); +} + +async function getNFTsFromInsight( + options: BaseTransactionOptions, +): Promise { + const { contract, start, count = Number(DEFAULT_QUERY_ALL_COUNT) } = options; + + const result = await getContractNFTs({ + client: contract.client, + chains: [contract.chain], + contractAddress: contract.address, + includeOwners: options.includeOwners ?? false, + queryOptions: { + limit: count, + page: start ? Math.floor(start / count) : undefined, + }, + }); + + return result; +} + +async function getNFTsFromRPC( + options: BaseTransactionOptions, ): Promise { const [startTokenId_, maxSupply] = await Promise.allSettled([ startTokenId(options), @@ -105,23 +164,3 @@ export async function getNFTs( return await Promise.all(promises); } - -/** - * Checks if the `getNFTs` method is supported by the given contract. - * @param availableSelectors An array of 4byte function selectors of the contract. You can get this in various ways, such as using "whatsabi" or if you have the ABI of the contract available you can use it to generate the selectors. - * @returns A boolean indicating if the `getNFTs` method is supported. - * @extension ERC721 - * @example - * ```ts - * import { isGetNFTsSupported } from "thirdweb/extensions/erc721"; - * - * const supported = isGetNFTsSupported(["0x..."]); - * ``` - */ -export function isGetNFTsSupported(availableSelectors: string[]) { - return ( - isGetNFTSupported(availableSelectors) && - (isTotalSupplySupported(availableSelectors) || - isNextTokenIdToMintSupported(availableSelectors)) - ); -} diff --git a/packages/thirdweb/src/extensions/erc721/read/getOwnedNFTs.test.ts b/packages/thirdweb/src/extensions/erc721/read/getOwnedNFTs.test.ts index 973fd719ec2..bf44630c0fa 100644 --- a/packages/thirdweb/src/extensions/erc721/read/getOwnedNFTs.test.ts +++ b/packages/thirdweb/src/extensions/erc721/read/getOwnedNFTs.test.ts @@ -6,12 +6,36 @@ import { getContract } from "../../../contract/contract.js"; import { getOwnedNFTs } from "./getOwnedNFTs.js"; describe.runIf(process.env.TW_SECRET_KEY)("erc721.getOwnedNFTs", () => { - it("should return the correct data", async () => { + it("should return the correct data using indexer", async () => { const owner = "0x3010775D16E7B79AF280035c64a1Df5F705CfdDb"; const nfts = await getOwnedNFTs({ contract: DOODLES_CONTRACT, owner, }); + expect(nfts.length).greaterThan(0); + }); + + it("should detect ownership functions using indexer", async () => { + const contract = getContract({ + client: TEST_CLIENT, + chain: defineChain(421614), + address: "0x90450885977EE8F8F21AC79Fc2Dd51a18B13123E", + }); + + const ownedNFTs = await getOwnedNFTs({ + contract, + owner: "0x1813D5Ff6f2B229a6Ba8FcDFa14004d91aa58e36", + }); + expect(ownedNFTs.length).greaterThan(0); + }); + + it("should return the correct data using RPC", async () => { + const owner = "0x3010775D16E7B79AF280035c64a1Df5F705CfdDb"; + const nfts = await getOwnedNFTs({ + contract: DOODLES_CONTRACT, + owner, + useIndexer: false, + }); // The following code is based on the state of the forked chain // so the data should not change @@ -21,7 +45,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getOwnedNFTs", () => { } }); - it("should detect ownership functions", async () => { + it("should detect ownership functions using RPC", async () => { const contract = getContract({ client: TEST_CLIENT, chain: defineChain(421614), @@ -31,6 +55,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc721.getOwnedNFTs", () => { const ownedNFTs = await getOwnedNFTs({ contract, owner: "0x1813D5Ff6f2B229a6Ba8FcDFa14004d91aa58e36", + useIndexer: false, }); expect(ownedNFTs.length).greaterThan(0); }); diff --git a/packages/thirdweb/src/extensions/erc721/read/getOwnedNFTs.ts b/packages/thirdweb/src/extensions/erc721/read/getOwnedNFTs.ts index 3dc3535dc12..a3be9968cd6 100644 --- a/packages/thirdweb/src/extensions/erc721/read/getOwnedNFTs.ts +++ b/packages/thirdweb/src/extensions/erc721/read/getOwnedNFTs.ts @@ -1,3 +1,4 @@ +import { getOwnedNFTs as getInsightNFTs } from "../../../insight/index.js"; import type { BaseTransactionOptions } from "../../../transaction/types.js"; import type { NFT } from "../../../utils/nft/parseNft.js"; import { getNFT } from "./getNFT.js"; @@ -9,7 +10,9 @@ import { /** * @extension ERC721 */ -export type GetOwnedNFTsParams = GetOwnedTokenIdsParams; +export type GetOwnedNFTsParams = GetOwnedTokenIdsParams & { + useIndexer?: boolean; +}; /** * Retrieves the owned NFTs for a given owner. @@ -29,6 +32,20 @@ export type GetOwnedNFTsParams = GetOwnedTokenIdsParams; */ export async function getOwnedNFTs( options: BaseTransactionOptions, +): Promise { + const { useIndexer = true } = options; + if (useIndexer) { + try { + return await getOwnedNFTsFromInsight(options); + } catch { + return await getOwnedNFTsFromRPC(options); + } + } + return await getOwnedNFTsFromRPC(options); +} + +async function getOwnedNFTsFromRPC( + options: BaseTransactionOptions, ): Promise { const tokenIds = await getOwnedTokenIds(options); @@ -45,3 +62,46 @@ export async function getOwnedNFTs( ), ); } + +async function getOwnedNFTsFromInsight( + options: BaseTransactionOptions, +): Promise { + const limit = 50; + const nfts: NFT[] = []; + let page = 0; + let hasMore = true; + + // TODO (insight): add support for contract address filters + while (hasMore) { + const pageResults = await getInsightNFTs({ + client: options.contract.client, + chains: [options.contract.chain], + ownerAddress: options.owner, + queryOptions: { + limit, + page, + }, + }); + + nfts.push(...pageResults); + + // If we got fewer results than the limit, we've reached the end + if (pageResults.length < limit) { + hasMore = false; + } else { + page++; + } + } + + const results = nfts; + + return results + .filter( + (n) => + n.tokenAddress.toLowerCase() === options.contract.address.toLowerCase(), + ) + .map((result) => ({ + ...result, + owner: options.owner, + })); +} diff --git a/packages/thirdweb/src/extensions/erc721/token721.test.ts b/packages/thirdweb/src/extensions/erc721/token721.test.ts index 77662a33b5e..a59d9777ac9 100644 --- a/packages/thirdweb/src/extensions/erc721/token721.test.ts +++ b/packages/thirdweb/src/extensions/erc721/token721.test.ts @@ -108,6 +108,8 @@ describe.runIf(process.env.TW_SECRET_KEY)("deployERC721", () => { tokenUri: "", type: "ERC721", owner: null, + tokenAddress: token721Contract.address, + chainId: token721Contract.chain.id, }, ), ); diff --git a/packages/thirdweb/src/extensions/marketplace/direct-listings/direct-listings.test.ts b/packages/thirdweb/src/extensions/marketplace/direct-listings/direct-listings.test.ts index 77b5d62a77e..77473b2ef82 100644 --- a/packages/thirdweb/src/extensions/marketplace/direct-listings/direct-listings.test.ts +++ b/packages/thirdweb/src/extensions/marketplace/direct-listings/direct-listings.test.ts @@ -204,15 +204,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("Marketplace Direct Listings", () => { expect(firstListing.creatorAddress).toBe(TEST_ACCOUNT_B.address); expect(firstListing.assetContractAddress).toBe(erc721Contract.address); expect(firstListing.tokenId).toBe(0n); - expect(firstListing.currencyValuePerToken).toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "1", - "name": "Anvil Ether", - "symbol": "ETH", - "value": 1000000000000000000n, - } - `); + expect(firstListing.currencyValuePerToken.displayValue).toBe("1"); expect(firstListing.asset.metadata.name).toBe("erc721 #0"); expect(firstListing.asset.id).toBe(0n); expect(firstListing.asset.owner).toBe(null); @@ -351,15 +343,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("Marketplace Direct Listings", () => { expect(secondListing.creatorAddress).toBe(TEST_ACCOUNT_C.address); expect(secondListing.assetContractAddress).toBe(erc1155Contract.address); expect(secondListing.tokenId).toBe(0n); - expect(secondListing.currencyValuePerToken).toMatchInlineSnapshot(` - { - "decimals": 18, - "displayValue": "0.05", - "name": "Anvil Ether", - "symbol": "ETH", - "value": 50000000000000000n, - } - `); + expect(secondListing.currencyValuePerToken.displayValue).toBe("0.05"); expect(secondListing.asset.metadata.name).toBe("erc1155 #0"); expect(secondListing.asset.id).toBe(0n); diff --git a/packages/thirdweb/src/extensions/marketplace/direct-listings/utils.ts b/packages/thirdweb/src/extensions/marketplace/direct-listings/utils.ts index 47e8d427bca..0a63b83f3e9 100644 --- a/packages/thirdweb/src/extensions/marketplace/direct-listings/utils.ts +++ b/packages/thirdweb/src/extensions/marketplace/direct-listings/utils.ts @@ -26,12 +26,13 @@ export async function mapDirectListing( endTimestamp: rawListing.endTimestamp, }); + const currencyContract = getContract({ + ...options.contract, + address: rawListing.currency, + }); const [currencyValuePerToken, nftAsset] = await Promise.all([ getCurrencyMetadata({ - contract: getContract({ - ...options.contract, - address: rawListing.currency, - }), + contract: currencyContract, }), getNFTAsset({ ...options, @@ -57,6 +58,8 @@ export async function mapDirectListing( rawListing.pricePerToken, currencyValuePerToken.decimals, ), + tokenAddress: currencyContract.address, + chainId: currencyContract.chain.id, }, pricePerToken: rawListing.pricePerToken, asset: nftAsset, diff --git a/packages/thirdweb/src/extensions/marketplace/english-auctions/utils.ts b/packages/thirdweb/src/extensions/marketplace/english-auctions/utils.ts index 2c7d14930dd..2b681448daa 100644 --- a/packages/thirdweb/src/extensions/marketplace/english-auctions/utils.ts +++ b/packages/thirdweb/src/extensions/marketplace/english-auctions/utils.ts @@ -24,12 +24,13 @@ export async function mapEnglishAuction( endTimestamp: rawAuction.endTimestamp, }); + const currencyContract = getContract({ + ...options.contract, + address: rawAuction.currency, + }); const [auctionCurrencyMetadata, nftAsset] = await Promise.all([ getCurrencyMetadata({ - contract: getContract({ - ...options.contract, - address: rawAuction.currency, - }), + contract: currencyContract, }), getNFTAsset({ ...options, @@ -60,6 +61,8 @@ export async function mapEnglishAuction( rawAuction.minimumBidAmount, auctionCurrencyMetadata.decimals, ), + tokenAddress: currencyContract.address, + chainId: currencyContract.chain.id, }, buyoutBidAmount: rawAuction.buyoutBidAmount, buyoutCurrencyValue: { @@ -69,6 +72,8 @@ export async function mapEnglishAuction( rawAuction.buyoutBidAmount, auctionCurrencyMetadata.decimals, ), + tokenAddress: currencyContract.address, + chainId: currencyContract.chain.id, }, timeBufferInSeconds: rawAuction.timeBufferInSeconds, bidBufferBps: rawAuction.bidBufferBps, diff --git a/packages/thirdweb/src/extensions/marketplace/offers/utils.ts b/packages/thirdweb/src/extensions/marketplace/offers/utils.ts index 1c8c5f45ccd..c2d9a5ededa 100644 --- a/packages/thirdweb/src/extensions/marketplace/offers/utils.ts +++ b/packages/thirdweb/src/extensions/marketplace/offers/utils.ts @@ -25,12 +25,13 @@ export async function mapOffer( endTimestamp: rawOffer.expirationTimestamp, }); + const currencyContract = getContract({ + ...options.contract, + address: rawOffer.currency, + }); const [currencyValuePerToken, nftAsset] = await Promise.all([ getCurrencyMetadata({ - contract: getContract({ - ...options.contract, - address: rawOffer.currency, - }), + contract: currencyContract, }), getNFTAsset({ ...options, @@ -56,6 +57,8 @@ export async function mapOffer( rawOffer.totalPrice, currencyValuePerToken.decimals, ), + tokenAddress: currencyContract.address, + chainId: currencyContract.chain.id, }, totalPrice: rawOffer.totalPrice, asset: nftAsset, diff --git a/packages/thirdweb/src/insight/common.ts b/packages/thirdweb/src/insight/common.ts new file mode 100644 index 00000000000..aec7da2e83a --- /dev/null +++ b/packages/thirdweb/src/insight/common.ts @@ -0,0 +1,24 @@ +import type { Chain } from "../chains/types.js"; +import { getChainServices } from "../chains/utils.js"; + +export async function assertInsightEnabled(chains: Chain[]) { + const chainData = await Promise.all( + chains.map((chain) => + getChainServices(chain).then((services) => ({ + chain, + enabled: services.some((c) => c.service === "insight" && c.enabled), + })), + ), + ); + + const insightEnabled = chainData.every((c) => c.enabled); + + if (!insightEnabled) { + throw new Error( + `Insight is not available for chains ${chainData + .filter((c) => !c.enabled) + .map((c) => c.chain.id) + .join(", ")}`, + ); + } +} diff --git a/packages/thirdweb/src/insight/get-events.ts b/packages/thirdweb/src/insight/get-events.ts index 4552bcdbf22..ba24c5ebf79 100644 --- a/packages/thirdweb/src/insight/get-events.ts +++ b/packages/thirdweb/src/insight/get-events.ts @@ -1,15 +1,11 @@ -import { - type GetV1EventsByContractAddressData, - type GetV1EventsByContractAddressResponse, - getV1EventsByContractAddress, +import type { + GetV1EventsByContractAddressData, + GetV1EventsByContractAddressResponse, } from "@thirdweb-dev/insight"; import type { AbiEvent } from "ox/AbiEvent"; -import { stringify } from "viem"; import type { Chain } from "../chains/types.js"; import type { ThirdwebClient } from "../client/client.js"; import type { PreparedEvent } from "../event/prepare-event.js"; -import { getThirdwebDomains } from "../utils/domains.js"; -import { getClientFetch } from "../utils/fetch.js"; export type ContractEvent = NonNullable< GetV1EventsByContractAddressResponse["data"] @@ -37,11 +33,30 @@ export async function getContractEvents(options: { contractAddress: string; event?: PreparedEvent; decodeLogs?: boolean; - queryOptions?: GetV1EventsByContractAddressData["query"]; + queryOptions?: Omit< + GetV1EventsByContractAddressData["query"], + "chain" | "decode" + >; }): Promise { + const [ + { getV1EventsByContractAddress }, + { getThirdwebDomains }, + { getClientFetch }, + { assertInsightEnabled }, + { stringify }, + ] = await Promise.all([ + import("@thirdweb-dev/insight"), + import("../utils/domains.js"), + import("../utils/fetch.js"), + import("./common.js"), + import("../utils/json.js"), + ]); + const { client, chains, contractAddress, event, queryOptions, decodeLogs } = options; + await assertInsightEnabled(chains); + const defaultQueryOptions: GetV1EventsByContractAddressData["query"] = { chain: chains.map((chain) => chain.id), limit: 100, @@ -62,7 +77,6 @@ export async function getContractEvents(options: { contractAddress, }, query: { - chain: chains.map((chain) => chain.id), ...defaultQueryOptions, ...queryOptions, }, diff --git a/packages/thirdweb/src/insight/get-nfts.ts b/packages/thirdweb/src/insight/get-nfts.ts index 4a6f6dcb1f3..a6f7f622588 100644 --- a/packages/thirdweb/src/insight/get-nfts.ts +++ b/packages/thirdweb/src/insight/get-nfts.ts @@ -1,15 +1,16 @@ -import { - type GetV1NftsBalanceByOwnerAddressData, - type GetV1NftsBalanceByOwnerAddressResponse, - getV1NftsBalanceByOwnerAddress, +import type { + GetV1NftsByContractAddressByTokenIdData, + GetV1NftsByContractAddressData, + GetV1NftsByContractAddressResponse, + GetV1NftsData, + GetV1NftsResponse, } from "@thirdweb-dev/insight"; -import { stringify } from "viem"; import type { Chain } from "../chains/types.js"; import type { ThirdwebClient } from "../client/client.js"; -import { getThirdwebDomains } from "../utils/domains.js"; -import { getClientFetch } from "../utils/fetch.js"; +import type { NFT } from "../utils/nft/parseNft.js"; -export type OwnedNFT = GetV1NftsBalanceByOwnerAddressResponse["data"][number]; +type OwnedNFT = GetV1NftsResponse["data"][number]; +type ContractNFT = GetV1NftsByContractAddressResponse["data"][number]; /** * Get NFTs owned by an address @@ -29,28 +30,199 @@ export async function getOwnedNFTs(args: { client: ThirdwebClient; chains: Chain[]; ownerAddress: string; - queryOptions?: GetV1NftsBalanceByOwnerAddressData["query"]; -}): Promise { + includeMetadata?: boolean; + queryOptions?: Omit; +}): Promise<(NFT & { quantityOwned: bigint })[]> { + const [ + { getV1Nfts }, + { getThirdwebDomains }, + { getClientFetch }, + { assertInsightEnabled }, + { stringify }, + ] = await Promise.all([ + import("@thirdweb-dev/insight"), + import("../utils/domains.js"), + import("../utils/fetch.js"), + import("./common.js"), + import("viem"), + ]); + + // TODO (insight): add support for contract address filters + const { client, chains, ownerAddress, queryOptions } = args; + + await assertInsightEnabled(chains); + + const defaultQueryOptions: GetV1NftsData["query"] = { + chain: chains.map((chain) => chain.id), + // metadata: includeMetadata ? "true" : "false", TODO (insight): add support for this + limit: 50, + owner_address: ownerAddress, + }; + + const result = await getV1Nfts({ + baseUrl: `https://${getThirdwebDomains().insight}`, + fetch: getClientFetch(client), + query: { + ...defaultQueryOptions, + ...queryOptions, + }, + }); + + if (result.error) { + throw new Error( + `${result.response.status} ${result.response.statusText} - ${result.error ? stringify(result.error) : "Unknown error"}`, + ); + } + + const transformedNfts = await transformNFTModel( + result.data?.data ?? [], + ownerAddress, + ); + return transformedNfts.map((nft) => ({ + ...nft, + quantityOwned: nft.quantityOwned ?? 1n, + })); +} + +/** + * Get all NFTs from a contract + * @example + * ```ts + * import { Insight } from "thirdweb"; + * + * const nfts = await Insight.getContractNFTs({ + * client, + * chains: [sepolia], + * contractAddress: "0x1234567890123456789012345678901234567890", + * }); + * ``` + * @insight + */ +export async function getContractNFTs(args: { + client: ThirdwebClient; + chains: Chain[]; + contractAddress: string; + includeMetadata?: boolean; + includeOwners?: boolean; + queryOptions?: Omit; +}): Promise { + const [ + { getV1NftsByContractAddress }, + { getThirdwebDomains }, + { getClientFetch }, + { assertInsightEnabled }, + { stringify }, + ] = await Promise.all([ + import("@thirdweb-dev/insight"), + import("../utils/domains.js"), + import("../utils/fetch.js"), + import("./common.js"), + import("../utils/json.js"), + ]); + const { client, chains, - ownerAddress, - queryOptions = { - chain: chains.map((chain) => chain.id), - metadata: "true", - limit: 100, - page: 1, + contractAddress, + includeOwners = true, + queryOptions, + } = args; + + const defaultQueryOptions: GetV1NftsByContractAddressData["query"] = { + chain: chains.map((chain) => chain.id), + // metadata: includeMetadata ? "true" : "false", TODO (insight): add support for this + limit: 50, + include_owners: + includeOwners === true ? ("true" as const) : ("false" as const), + }; + + await assertInsightEnabled(chains); + + const result = await getV1NftsByContractAddress({ + baseUrl: `https://${getThirdwebDomains().insight}`, + fetch: getClientFetch(client), + path: { + contract_address: contractAddress, + }, + query: { + ...defaultQueryOptions, + ...queryOptions, }, + }); + + if (result.error) { + throw new Error( + `${result.response.status} ${result.response.statusText} - ${result.error ? stringify(result.error) : "Unknown error"}`, + ); + } + + return transformNFTModel(result.data?.data ?? []); +} + +/** + * Get NFT metadata by contract address and token id + * @example + * ```ts + * import { Insight } from "thirdweb"; + * + * const nft = await Insight.getNFT({ + * client, + * chain: sepolia, + * contractAddress: "0x1234567890123456789012345678901234567890", + * tokenId: 1n, + * }); + * ``` + * @insight + */ +export async function getNFT(args: { + client: ThirdwebClient; + chain: Chain; + contractAddress: string; + tokenId: bigint | number | string; + includeOwners?: boolean; + queryOptions?: GetV1NftsByContractAddressByTokenIdData["query"]; +}): Promise { + const [ + { getV1NftsByContractAddressByTokenId }, + { getThirdwebDomains }, + { getClientFetch }, + { assertInsightEnabled }, + { stringify }, + ] = await Promise.all([ + import("@thirdweb-dev/insight"), + import("../utils/domains.js"), + import("../utils/fetch.js"), + import("./common.js"), + import("../utils/json.js"), + ]); + + const { + client, + chain, + contractAddress, + tokenId, + includeOwners = true, + queryOptions, } = args; - const result = await getV1NftsBalanceByOwnerAddress({ + await assertInsightEnabled([chain]); + + const defaultQueryOptions: GetV1NftsByContractAddressByTokenIdData["query"] = + { + chain: chain.id, + include_owners: + includeOwners === true ? ("true" as const) : ("false" as const), + }; + + const result = await getV1NftsByContractAddressByTokenId({ baseUrl: `https://${getThirdwebDomains().insight}`, fetch: getClientFetch(client), path: { - ownerAddress: ownerAddress, + contract_address: contractAddress, + token_id: tokenId.toString(), }, query: { - chain: chains.map((chain) => chain.id), + ...defaultQueryOptions, ...queryOptions, }, }); @@ -61,5 +233,64 @@ export async function getOwnedNFTs(args: { ); } - return result.data?.data ?? []; + const transformedNfts = await transformNFTModel(result.data?.data ?? []); + return transformedNfts?.[0]; +} + +async function transformNFTModel( + nfts: (ContractNFT | OwnedNFT)[], + ownerAddress?: string, +): Promise<(NFT & { quantityOwned?: bigint })[]> { + const { parseNFT } = await import("../utils/nft/parseNft.js"); + + return nfts.map((nft) => { + let parsedNft: NFT; + const { + contract, + extra_metadata, + collection, + metadata_url, + chain_id, + token_id, + status, + balance, + token_type, + ...rest + } = nft; + const metadata = { + uri: nft.metadata_url ?? "", + image: nft.image_url, + attributes: nft.extra_metadata?.attributes ?? undefined, + ...rest, + }; + + const owner_addresses = ownerAddress + ? [ownerAddress] + : "owner_addresses" in nft + ? nft.owner_addresses + : undefined; + + if (contract?.type === "erc1155") { + parsedNft = parseNFT(metadata, { + tokenId: BigInt(token_id), + tokenUri: metadata_url ?? "", + type: "ERC1155", + owner: owner_addresses?.[0], + tokenAddress: contract?.address ?? "", + chainId: contract?.chain_id ?? 0, + supply: balance ? BigInt(balance) : 0n, // TODO (insight): this is wrong, needs to be added in the API + }); + } else { + parsedNft = parseNFT(metadata, { + tokenId: BigInt(token_id), + type: "ERC721", + owner: owner_addresses?.[0], + tokenUri: metadata_url ?? "", + tokenAddress: contract?.address ?? "", + chainId: contract?.chain_id ?? 0, + }); + } + + return parsedNft; + }); } diff --git a/packages/thirdweb/src/insight/get-tokens.ts b/packages/thirdweb/src/insight/get-tokens.ts index 7adcb419fec..f41b54ad4ef 100644 --- a/packages/thirdweb/src/insight/get-tokens.ts +++ b/packages/thirdweb/src/insight/get-tokens.ts @@ -1,15 +1,12 @@ -import { - type GetV1TokensErc20ByOwnerAddressData, - type GetV1TokensErc20ByOwnerAddressResponse, - getV1TokensErc20ByOwnerAddress, +import type { + GetV1TokensErc20ByOwnerAddressData, + GetV1TokensErc20ByOwnerAddressResponse, } from "@thirdweb-dev/insight"; -import { stringify } from "viem"; import type { Chain } from "../chains/types.js"; import type { ThirdwebClient } from "../client/client.js"; -import { getThirdwebDomains } from "../utils/domains.js"; -import { getClientFetch } from "../utils/fetch.js"; +import type { GetWalletBalanceResult } from "../wallets/utils/getWalletBalance.js"; -export type OwnedToken = GetV1TokensErc20ByOwnerAddressResponse["data"][number]; +type OwnedToken = GetV1TokensErc20ByOwnerAddressResponse["data"][number]; /** * Get ERC20 tokens owned by an address @@ -30,19 +27,31 @@ export async function getOwnedTokens(args: { chains: Chain[]; ownerAddress: string; queryOptions?: GetV1TokensErc20ByOwnerAddressData["query"]; -}): Promise { - const { - client, - chains, - ownerAddress, - queryOptions = { - chain: chains.map((chain) => chain.id), - include_spam: "false", - metadata: "true", - limit: 100, - page: 1, - }, - } = args; +}): Promise { + const [ + { getV1TokensErc20ByOwnerAddress }, + { getThirdwebDomains }, + { getClientFetch }, + { assertInsightEnabled }, + { stringify }, + ] = await Promise.all([ + import("@thirdweb-dev/insight"), + import("../utils/domains.js"), + import("../utils/fetch.js"), + import("./common.js"), + import("../utils/json.js"), + ]); + + const { client, chains, ownerAddress, queryOptions } = args; + + await assertInsightEnabled(chains); + + const defaultQueryOptions: GetV1TokensErc20ByOwnerAddressData["query"] = { + chain: chains.map((chain) => chain.id), + include_spam: "false", + metadata: "true", + limit: 50, + }; const result = await getV1TokensErc20ByOwnerAddress({ baseUrl: `https://${getThirdwebDomains().insight}`, @@ -51,7 +60,7 @@ export async function getOwnedTokens(args: { ownerAddress: ownerAddress, }, query: { - chain: chains.map((chain) => chain.id), + ...defaultQueryOptions, ...queryOptions, }, }); @@ -62,5 +71,24 @@ export async function getOwnedTokens(args: { ); } - return result.data?.data ?? []; + return transformOwnedToken(result.data?.data ?? []); +} + +async function transformOwnedToken( + token: OwnedToken[], +): Promise { + const { toTokens } = await import("../utils/units.js"); + return token.map((t) => { + const decimals = t.decimals ?? 18; + const value = BigInt(t.balance); + return { + value, + displayValue: toTokens(value, decimals), + tokenAddress: t.token_address, + chainId: t.chain_id, + decimals, + symbol: t.symbol ?? "", + name: t.name ?? "", + }; + }); } diff --git a/packages/thirdweb/src/insight/get-transactions.ts b/packages/thirdweb/src/insight/get-transactions.ts index eee4afc563e..af70207af5c 100644 --- a/packages/thirdweb/src/insight/get-transactions.ts +++ b/packages/thirdweb/src/insight/get-transactions.ts @@ -1,13 +1,9 @@ -import { - type GetV1WalletsByWalletAddressTransactionsData, - type GetV1WalletsByWalletAddressTransactionsResponse, - getV1WalletsByWalletAddressTransactions, +import type { + GetV1WalletsByWalletAddressTransactionsData, + GetV1WalletsByWalletAddressTransactionsResponse, } from "@thirdweb-dev/insight"; -import { stringify } from "viem"; import type { Chain } from "../chains/types.js"; import type { ThirdwebClient } from "../client/client.js"; -import { getThirdwebDomains } from "../utils/domains.js"; -import { getClientFetch } from "../utils/fetch.js"; export type Transaction = NonNullable< GetV1WalletsByWalletAddressTransactionsResponse["data"] @@ -33,24 +29,38 @@ export async function getTransactions(args: { chains: Chain[]; queryOptions?: GetV1WalletsByWalletAddressTransactionsData["query"]; }): Promise { + const [ + { getV1WalletsByWalletAddressTransactions }, + { getThirdwebDomains }, + { getClientFetch }, + { assertInsightEnabled }, + { stringify }, + ] = await Promise.all([ + import("@thirdweb-dev/insight"), + import("../utils/domains.js"), + import("../utils/fetch.js"), + import("./common.js"), + import("../utils/json.js"), + ]); + + await assertInsightEnabled(args.chains); const threeMonthsAgoInSeconds = Math.floor( (Date.now() - 3 * 30 * 24 * 60 * 60 * 1000) / 1000, ); - const { - client, - walletAddress, - chains, - queryOptions = { + const { client, walletAddress, chains, queryOptions } = args; + + const defaultQueryOptions: GetV1WalletsByWalletAddressTransactionsData["query"] = + { + chain: chains.map((chain) => chain.id), filter_block_timestamp_gte: threeMonthsAgoInSeconds, limit: 100, - page: 1, - }, - } = args; + }; + const result = await getV1WalletsByWalletAddressTransactions({ baseUrl: `https://${getThirdwebDomains().insight}`, fetch: getClientFetch(client), query: { - chain: chains.map((chain) => chain.id), + ...defaultQueryOptions, ...queryOptions, }, path: { diff --git a/packages/thirdweb/src/insight/index.ts b/packages/thirdweb/src/insight/index.ts index 8f14be07662..885a8d0f68c 100644 --- a/packages/thirdweb/src/insight/index.ts +++ b/packages/thirdweb/src/insight/index.ts @@ -1,4 +1,8 @@ -export { getOwnedNFTs, type OwnedNFT } from "./get-nfts.js"; -export { getOwnedTokens, type OwnedToken } from "./get-tokens.js"; +export { + getContractNFTs, + getOwnedNFTs, + getNFT, +} from "./get-nfts.js"; +export { getOwnedTokens } from "./get-tokens.js"; export { getTransactions, type Transaction } from "./get-transactions.js"; export { getContractEvents, type ContractEvent } from "./get-events.js"; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewNFTs.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewNFTs.tsx index fb4e4ce624a..fa07321d0a2 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewNFTs.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewNFTs.tsx @@ -130,53 +130,12 @@ export function ViewNFTsContent(props: { }); return result - .filter((nft) => !!nft.name && !!nft.image_url) + .filter((nft) => !!nft.metadata.name && !!nft.metadata.image) .map((nft) => { - let parsedNft: NFT; - const metadata = { - name: nft.name, - description: nft.description, - image: nft.image_url, - animation_url: nft.video_url, - external_url: nft.external_url, - background_color: nft.background_color, - uri: nft.metadata_url ?? "", - image_url: nft.image_url, - attributes: Array.isArray(nft.extra_metadata?.attributes) - ? nft.extra_metadata?.attributes?.reduce( - (acc, attr) => { - acc[attr.trait_type] = attr.value; - return acc; - }, - {} as Record, - ) - : {}, - }; - - if (nft.contract?.type === "erc1155") { - parsedNft = { - id: BigInt(nft.token_id), - type: "ERC1155", - owner: activeAccount.address, - tokenURI: nft.metadata_url ?? "", - supply: BigInt(nft.balance), // TODO: this is wrong - metadata, - }; - } else { - parsedNft = { - id: BigInt(nft.token_id), - type: "ERC721", - owner: activeAccount.address, - tokenURI: nft.metadata_url ?? "", - metadata, - }; - } - return { - chain: getCachedChain(nft.chain_id), - address: nft.token_address as Address, - quantityOwned: BigInt(nft.balance), - ...parsedNft, + chain: getCachedChain(nft.chainId), + address: nft.tokenAddress as Address, + ...nft, }; }); }, @@ -197,29 +156,45 @@ export function ViewNFTsContent(props: { return ( <> - - {nftQuery.error ? ( - Error loading NFTs - ) : nftQuery.isLoading || !filteredNFTs ? ( - - ) : ( - filteredNFTs.map((nft) => ( - - )) - )} - + {nftQuery.error ? ( + + + Error loading NFTs + + + ) : nftQuery.data?.length === 0 && !nftQuery.isLoading ? ( + + + No NFTs found on this chain + + + ) : ( + + {nftQuery.isLoading || !filteredNFTs ? ( + <> + + + + + ) : ( + filteredNFTs.map((nft) => ( + + )) + )} + + )} ); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewTokens.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewTokens.tsx index 3daf0ab885a..9f1d69c6a0e 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewTokens.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ViewTokens.tsx @@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"; import type { Chain } from "../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; import { getOwnedTokens } from "../../../../../insight/get-tokens.js"; -import { toTokens } from "../../../../../utils/units.js"; import { fontSize } from "../../../../core/design-system/index.js"; import { useWalletBalance } from "../../../../core/hooks/others/useWalletBalance.js"; import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccount.js"; @@ -94,8 +93,7 @@ export function ViewTokensContent(props: { return result.filter( (token) => !defaultTokens[activeChain.id]?.some( - (t) => - t.address.toLowerCase() === token.token_address.toLowerCase(), + (t) => t.address.toLowerCase() === token.tokenAddress.toLowerCase(), ), ); }, @@ -139,22 +137,14 @@ export function ViewTokensContent(props: { return ( ); })} diff --git a/packages/thirdweb/src/react/web/ui/components/TokenIcon.tsx b/packages/thirdweb/src/react/web/ui/components/TokenIcon.tsx index 0949a0efdbb..66c4f0ddc92 100644 --- a/packages/thirdweb/src/react/web/ui/components/TokenIcon.tsx +++ b/packages/thirdweb/src/react/web/ui/components/TokenIcon.tsx @@ -6,12 +6,13 @@ import { NATIVE_TOKEN_ADDRESS } from "../../../../constants/addresses.js"; import { iconSize } from "../../../core/design-system/index.js"; import { useChainIconUrl } from "../../../core/hooks/others/useChainQuery.js"; import { genericTokenIcon } from "../../../core/utils/walletIcon.js"; +import { CoinsIcon } from "../ConnectWallet/icons/CoinsIcon.js"; import { type NativeToken, isNativeToken, } from "../ConnectWallet/screens/nativeToken.js"; import { Img } from "./Img.js"; - +import { Container } from "./basic.js"; /** * @internal */ @@ -38,13 +39,21 @@ export function TokenIcon(props: { return props.token.icon; }, [props.token, chainIconQuery.url]); - return ( + return tokenImage ? ( + ) : ( + + + ); } diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.test.ts b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.test.ts index df396084579..9f3fa5d3c15 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.test.ts +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.test.ts @@ -12,40 +12,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("getNFTInfo", () => { contract: DOODLES_CONTRACT, tokenId: 0n, }); - expect(nft).toStrictEqual({ - id: 0n, - metadata: { - attributes: [ - { - trait_type: "face", - value: "mustache", - }, - { - trait_type: "hair", - value: "purple long", - }, - { - trait_type: "body", - value: "blue and yellow jacket", - }, - { - trait_type: "background", - value: "green", - }, - { - trait_type: "head", - value: "tan", - }, - ], - description: - "A community-driven collectibles project featuring art by Burnt Toast. Doodles come in a joyful range of colors, traits and sizes with a collection size of 10,000. Each Doodle allows its owner to vote for experiences and activations paid for by the Doodles Community Treasury. Burnt Toast is the working alias for Scott Martin, a Canadian–based illustrator, designer, animator and muralist.", - image: "ipfs://QmUEfFfwAh4wyB5UfHCVPUxis4j4Q4kJXtm5x5p3g1fVUn", - name: "Doodle #0", - }, - owner: null, - tokenURI: "ipfs://QmPMc4tcBsMqLRuCQtPmPe84bpSjrC3Ky7t3JWuHXYB4aS/0", - type: "ERC721", - }); + expect(nft.metadata.name).toBe("Doodle #0"); }); it("should work with ERC1155", async () => { @@ -53,36 +20,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("getNFTInfo", () => { contract: DROP1155_CONTRACT, tokenId: 0n, }); - expect(nft).toStrictEqual({ - id: 0n, - metadata: { - animation_url: - "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/1.mp4", - attributes: [ - { - trait_type: "Revenue Share", - value: "40%", - }, - { - trait_type: "Max Supply", - value: "50", - }, - { - trait_type: "Max Per Wallet", - value: "1", - }, - ], - background_color: "", - description: "", - external_url: "https://auraexchange.org", - image: "ipfs://QmeGCqV1mSHTZrvuFzW1XZdCRRGXB6AmSotTqHoxA2xfDo/0.png", - name: "Aura OG", - }, - owner: null, - supply: 33n, - tokenURI: "ipfs://QmNgevzVNwJWJdErFY2B7KsuKdJz3gVuBraNKaSxPktLh5/0", - type: "ERC1155", - }); + expect(nft.metadata.name).toBe("Aura OG"); }); it("should throw error if failed to load nft info", async () => { diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.ts b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.ts index a21c243bb58..812cee1c3e0 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.ts +++ b/packages/thirdweb/src/react/web/ui/prebuilt/NFT/utils.ts @@ -11,8 +11,13 @@ export async function getNFTInfo(options: NFTProviderProps): Promise { return withCache( async () => { const nft = await Promise.allSettled([ - getNFT721(options), - getNFT1155(options), + getNFT721({ + ...options, + useIndexer: false, // TODO (insight): switch this call to only call insight once + }), + getNFT1155({ + ...options, + }), ]).then(([possibleNFT721, possibleNFT1155]) => { // getNFT extension always return an NFT object // so we need to check if the tokenURI exists diff --git a/packages/thirdweb/src/social/profiles.ts b/packages/thirdweb/src/social/profiles.ts index d19cddbe86b..0c6b4861aa5 100644 --- a/packages/thirdweb/src/social/profiles.ts +++ b/packages/thirdweb/src/social/profiles.ts @@ -32,15 +32,11 @@ export async function getSocialProfiles(args: { `${getThirdwebBaseUrl("social")}/v1/profiles/${address}`, ); - if (response.status !== 200) { - try { - const errorBody = await response.json(); - throw new Error(`Failed to fetch profile: ${errorBody.message}`); - } catch { - throw new Error( - `Failed to fetch profile: ${response.status}\n${await response.text()}`, - ); - } + if (!response.ok) { + const errorBody = await response.text().catch(() => "Unknown error"); + throw new Error( + `Failed to fetch profile: ${response.status} ${response.statusText} - ${errorBody}`, + ); } return (await response.json()).data as SocialProfile[]; diff --git a/packages/thirdweb/src/storage/download.ts b/packages/thirdweb/src/storage/download.ts index 316396b993b..e85ecae3184 100644 --- a/packages/thirdweb/src/storage/download.ts +++ b/packages/thirdweb/src/storage/download.ts @@ -99,8 +99,10 @@ export async function download(options: DownloadOptions) { }); if (!res.ok) { - res.body?.cancel(); - throw new Error(`Failed to download file: ${res.statusText}`); + const error = await res.text(); + throw new Error( + `Failed to download file: ${res.status} ${res.statusText} ${error || ""}`, + ); } return res; } diff --git a/packages/thirdweb/src/storage/unpin.ts b/packages/thirdweb/src/storage/unpin.ts index bbeba734568..c32d08ffef2 100644 --- a/packages/thirdweb/src/storage/unpin.ts +++ b/packages/thirdweb/src/storage/unpin.ts @@ -41,12 +41,14 @@ export async function unpin(options: UnpinOptions) { ); if (!res.ok) { - res.body?.cancel(); if (res.status === 401) { throw new Error( "Unauthorized - You don't have permission to use this service.", ); } - throw new Error(`Failed to unpin file - ${res.status} - ${res.statusText}`); + const error = await res.text(); + throw new Error( + `Failed to unpin file - ${res.status} - ${res.statusText} ${error || ""}`, + ); } } diff --git a/packages/thirdweb/src/storage/upload/web-node.ts b/packages/thirdweb/src/storage/upload/web-node.ts index e328fb35a38..7086c592838 100644 --- a/packages/thirdweb/src/storage/upload/web-node.ts +++ b/packages/thirdweb/src/storage/upload/web-node.ts @@ -29,7 +29,6 @@ export async function uploadBatch( ); if (!res.ok) { - res.body?.cancel(); if (res.status === 401) { throw new Error( "Unauthorized - You don't have permission to use this service.", diff --git a/packages/thirdweb/src/utils/nft/parseNft.test.ts b/packages/thirdweb/src/utils/nft/parseNft.test.ts index 84ebb78d6dd..898caa9d3ef 100644 --- a/packages/thirdweb/src/utils/nft/parseNft.test.ts +++ b/packages/thirdweb/src/utils/nft/parseNft.test.ts @@ -21,6 +21,8 @@ describe("parseNft", () => { tokenId: 0n, tokenUri: "ipfs://", type: "ERC721", + tokenAddress: "0x1234567890123456789012345678901234567890", + chainId: 1, }; const result = parseNFT(base, option); const expectedResult: NFT = { @@ -29,6 +31,8 @@ describe("parseNft", () => { id: option.tokenId, tokenURI: option.tokenUri, type: option.type, + tokenAddress: option.tokenAddress, + chainId: option.chainId, }; expect(result).toMatchObject(expectedResult); }); @@ -39,6 +43,8 @@ describe("parseNft", () => { tokenUri: "ipfs://", type: "ERC1155", supply: 10n, + tokenAddress: "0x1234567890123456789012345678901234567890", + chainId: 1, }; const expectedResult: NFT = { metadata: base, @@ -47,6 +53,8 @@ describe("parseNft", () => { tokenURI: option.tokenUri, type: option.type, supply: option.supply, + tokenAddress: option.tokenAddress, + chainId: option.chainId, }; const result = parseNFT(base, option); expect(result).toMatchObject(expectedResult); diff --git a/packages/thirdweb/src/utils/nft/parseNft.ts b/packages/thirdweb/src/utils/nft/parseNft.ts index c6ba48397a3..3996a5284b3 100644 --- a/packages/thirdweb/src/utils/nft/parseNft.ts +++ b/packages/thirdweb/src/utils/nft/parseNft.ts @@ -41,6 +41,8 @@ export type NFT = id: bigint; tokenURI: string; type: "ERC721"; + tokenAddress: string; + chainId: number; } | { metadata: NFTMetadata; @@ -49,6 +51,8 @@ export type NFT = tokenURI: string; type: "ERC1155"; supply: bigint; + tokenAddress: string; + chainId: number; }; /** @@ -60,6 +64,8 @@ export type ParseNFTOptions = tokenUri: string; type: "ERC721"; owner?: string | null; + tokenAddress: string; + chainId: number; } | { tokenId: bigint; @@ -67,6 +73,8 @@ export type ParseNFTOptions = type: "ERC1155"; owner?: string | null; supply: bigint; + tokenAddress: string; + chainId: number; }; /** @@ -85,6 +93,8 @@ export function parseNFT(base: NFTMetadata, options: ParseNFTOptions): NFT { id: options.tokenId, tokenURI: options.tokenUri, type: options.type, + tokenAddress: options.tokenAddress, + chainId: options.chainId, }; case "ERC1155": return { @@ -94,6 +104,8 @@ export function parseNFT(base: NFTMetadata, options: ParseNFTOptions): NFT { tokenURI: options.tokenUri, type: options.type, supply: options.supply, + tokenAddress: options.tokenAddress, + chainId: options.chainId, }; default: throw new Error("Invalid NFT type"); diff --git a/packages/thirdweb/src/utils/signatures/resolve-signature.ts b/packages/thirdweb/src/utils/signatures/resolve-signature.ts index fe1eea7952d..69bf5f21a9e 100644 --- a/packages/thirdweb/src/utils/signatures/resolve-signature.ts +++ b/packages/thirdweb/src/utils/signatures/resolve-signature.ts @@ -17,7 +17,6 @@ async function resolveFunctionSignature( `${SIGNATURE_API}/signatures/?format=json&hex_signature=${hexSig}`, ); if (!res.ok) { - res.body?.cancel(); return null; } const data = await res.json(); @@ -39,7 +38,6 @@ async function resolveEventSignature( `${SIGNATURE_API}/event-signatures/?format=json&hex_signature=${hexSig}`, ); if (!res.ok) { - res.body?.cancel(); return null; } const data = await res.json(); diff --git a/packages/thirdweb/src/wallets/smart/lib/calls.ts b/packages/thirdweb/src/wallets/smart/lib/calls.ts index 617818cd424..c252d90247a 100644 --- a/packages/thirdweb/src/wallets/smart/lib/calls.ts +++ b/packages/thirdweb/src/wallets/smart/lib/calls.ts @@ -114,8 +114,8 @@ export async function predictAddress(args: { throw error; } - // Exponential backoff: 2^(retries + 1) * 100ms (200ms, 400ms, 800ms) - const delay = 2 ** (retries + 1) * 100; + // Exponential backoff: 2^(retries + 1) * 200ms (400ms, 800ms, 1600ms) + const delay = 2 ** (retries + 1) * 200; await new Promise((resolve) => setTimeout(resolve, delay)); retries++; } diff --git a/packages/thirdweb/src/wallets/utils/getWalletBalance.ts b/packages/thirdweb/src/wallets/utils/getWalletBalance.ts index 6affc8bd2a3..24b1a25512e 100644 --- a/packages/thirdweb/src/wallets/utils/getWalletBalance.ts +++ b/packages/thirdweb/src/wallets/utils/getWalletBalance.ts @@ -5,7 +5,9 @@ import { getChainSymbol, } from "../../chains/utils.js"; import type { ThirdwebClient } from "../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../constants/addresses.js"; import { getContract } from "../../contract/contract.js"; +import type { GetBalanceResult } from "../../extensions/erc20/read/getBalance.js"; import { eth_getBalance } from "../../rpc/actions/eth_getBalance.js"; import { getRpcClient } from "../../rpc/rpc.js"; import { toTokens } from "../../utils/units.js"; @@ -20,13 +22,7 @@ export type GetWalletBalanceOptions = { tokenAddress?: string; }; -export type GetWalletBalanceResult = { - value: bigint; - decimals: number; - displayValue: string; - symbol: string; - name: string; -}; +export type GetWalletBalanceResult = GetBalanceResult; /** * Retrieves the balance of a token or native currency for a given wallet. @@ -75,5 +71,7 @@ export async function getWalletBalance( displayValue: toTokens(nativeBalance, nativeDecimals), symbol: nativeSymbol, name: nativeName, + tokenAddress: tokenAddress ?? NATIVE_TOKEN_ADDRESS, + chainId: chain.id, }; } diff --git a/packages/thirdweb/test/src/test-clients.ts b/packages/thirdweb/test/src/test-clients.ts index 90f04174188..19bfb06e78d 100644 --- a/packages/thirdweb/test/src/test-clients.ts +++ b/packages/thirdweb/test/src/test-clients.ts @@ -1,11 +1,13 @@ import { createThirdwebClient } from "../../src/client/client.js"; const secretKey = process.env.TW_SECRET_KEY; +const clientId = process.env.TW_CLIENT_ID; export const TEST_CLIENT = createThirdwebClient( secretKey ? { secretKey, + clientId: clientId ?? undefined, } : { clientId: "TEST",