UNPKG

nft-did-resolver

Version:
215 lines 8.47 kB
import { Caip10Link } from '@ceramicnetwork/stream-caip10-link'; import { ChainId, AccountId, AssetId } from 'caip'; import { blockAtTime, erc1155OwnersOf, erc721OwnerOf, isWithinLastBlock } from './subgraph-utils.js'; import merge from 'merge-options'; const DID_LD_JSON = 'application/did+ld+json'; const DID_JSON = 'application/did+json'; export function didToCaip(id) { const caip = id .replace(/^did:nft:/, '') .replace(/\?.+$/, '') .replace(/_/g, '/'); return new AssetId(AssetId.parse(caip)); } async function assetToAccount(asset, timestamp, chains) { const assetChainId = asset.chainId.toString(); const chain = chains[assetChainId]; if (!chain) { throw new Error(`No chain configuration for ${assetChainId}`); } // we want to query what block is at the timestamp IFF it is an (older) existing timestamp let queryBlock = 0; if (timestamp && !isWithinLastBlock(timestamp, chain.skew)) { queryBlock = await blockAtTime(timestamp, chain.blocks); } let owners; let ercSubgraphUrls = undefined; if (chains && chains[assetChainId]) { ercSubgraphUrls = chains[assetChainId].assets; } if (asset.assetName.namespace === 'erc721') { owners = [await erc721OwnerOf(asset, queryBlock, ercSubgraphUrls?.erc721)]; } else if (asset.assetName.namespace === 'erc1155') { owners = await erc1155OwnersOf(asset, queryBlock, ercSubgraphUrls?.erc1155); } else { throw new Error(`Only erc721 and erc1155 namespaces are currently supported. Given: ${asset.assetName.namespace}`); } return owners.slice().map((owner) => new AccountId({ chainId: asset.chainId, address: owner, })); } /** * Creates CAIP-10 links for each account to be used as controllers. * Since there may be many owners for a given NFT (only ERC1155 for now), * there can be many controllers of that DID document. */ async function accountsToDids(accounts, timestamp, ceramic) { const controllers = []; const links = await Promise.all(accounts.map((accountId) => Caip10Link.fromAccount(ceramic, accountId))); for (const link of links) { if (link?.did) controllers.push(link.did); } return controllers.length > 0 ? controllers : undefined; } function wrapDocument(did, accounts, controllers) { // Each of the owning accounts is a verification method (at the point in time) const verificationMethods = accounts.slice().map((account) => { return { id: `${did}#${account.address.slice(2, 14)}`, type: 'BlockchainVerificationMethod2021', controller: did, blockchainAccountId: account.toString(), }; }); const doc = { id: did, verificationMethod: [...verificationMethods], }; // Controllers should only be an array when there're more than one if (controllers) doc.controller = controllers.length === 1 ? controllers[0] : controllers; return doc; } /** * Gets the unix timestamp from the `versionTime` parameter. * @param query */ function getVersionTime(query = '') { const versionTime = query.split('&').find((e) => e.includes('versionTime')); if (versionTime) { return Math.floor(new Date(versionTime.split('=')[1]).getTime() / 1000); } return 0; // 0 is falsey } function validateResolverConfig(config) { if (!config) { throw new Error(`Missing nft-did-resolver config`); } if (!config.ceramic) { throw new Error('Missing ceramic client in nft-did-resolver config'); } const chains = config.chains; if (!chains) { throw new Error('Missing chain parameters in nft-did-resolver config'); } try { Object.entries(config.chains).forEach(([chainId, chainConfig]) => { ChainId.parse(chainId); new URL(chainConfig.blocks); Object.values(chainConfig.assets).forEach((subgraph) => { new URL(subgraph); }); }); } catch (e) { throw new Error(`Invalid config for nft-did-resolver: ${e.message}`); } } async function resolve(did, methodId, timestamp, config) { const asset = didToCaip(methodId); // for 1155s, there can be many accounts that own a single asset const owningAccounts = await assetToAccount(asset, timestamp, config.chains); const controllers = await accountsToDids(owningAccounts, timestamp, config.ceramic); const metadata = {}; // TODO create (if it stays in the spec) return { didResolutionMetadata: { contentType: DID_JSON }, didDocument: wrapDocument(did, owningAccounts, controllers), didDocumentMetadata: metadata, }; } /** * Convert AssetId to did:nft URL, including timestamp, if present. * @param assetId - NFT Asset * @param timestamp - JS Time as unix timestamp. */ export function caipToDid(assetId, timestamp) { const query = timestamp ? `?versionTime=${new Date(timestamp * 1000).toISOString().split('.')[0] + 'Z'}` : ''; const id = assetId.toString().replace(/\//g, '_'); return `did:nft:${id}${query}`; } function withDefaultConfig(config) { const defaults = { chains: { 'eip155:1': { blocks: 'https://api.thegraph.com/subgraphs/name/yyong1010/ethereumblocks', skew: 15000, assets: { erc721: 'https://api.thegraph.com/subgraphs/name/sunguru98/mainnet-erc721-subgraph', erc1155: 'https://api.thegraph.com/subgraphs/name/sunguru98/mainnet-erc1155-subgraph', }, }, 'eip155:4': { blocks: 'https://api.thegraph.com/subgraphs/name/mul53/rinkeby-blocks', skew: 15000, assets: { erc721: 'https://api.thegraph.com/subgraphs/name/sunguru98/erc721-rinkeby-subgraph', erc1155: 'https://api.thegraph.com/subgraphs/name/sunguru98/erc1155-rinkeby-subgraph', }, }, 'eip155:137': { blocks: 'https://api.thegraph.com/subgraphs/name/matthewlilley/polygon-blocks', skew: 2200, assets: { erc721: 'https://api.thegraph.com/subgraphs/name/yellow-heart/maticsubgraph', erc1155: 'https://api.thegraph.com/subgraphs/name/tranchien2002/eip1155-matic', }, }, }, }; return merge.bind({ ignoreUndefined: true })(defaults, config); } /** * Convert NFT asset id to NFT DID URL. Can include timestamp (as unix timestamp) if provided. */ export function createNftDidUrl(params) { if (!['erc721', 'erc1155'].includes(params.namespace)) { throw new Error(`Only erc721 and erc1155 are supported`); } return caipToDid(new AssetId({ chainId: params.chainId, assetName: `${params.namespace}:${params.contract}`, tokenId: params.tokenId, }), params.timestamp); } export function getResolver(config) { config = withDefaultConfig(config); validateResolverConfig(config); return { nft: async (did, parsed, resolver, options) => { const contentType = options.accept || DID_JSON; try { const timestamp = getVersionTime(parsed.query); const didResult = await resolve(did, parsed.id, timestamp, config); if (contentType === DID_LD_JSON) { didResult.didDocument['@context'] = 'https://w3id.org/did/v1'; didResult.didResolutionMetadata.contentType = DID_LD_JSON; } else if (contentType !== DID_JSON) { didResult.didDocument = null; didResult.didDocumentMetadata = {}; delete didResult.didResolutionMetadata.contentType; didResult.didResolutionMetadata.error = 'representationNotSupported'; } return didResult; } catch (e) { return { didResolutionMetadata: { error: 'invalidDid', message: e.toString(), }, didDocument: null, didDocumentMetadata: {}, }; } }, }; } //# sourceMappingURL=index.js.map