diff --git a/src/forwarder/mirror-node-client.js b/src/forwarder/mirror-node-client.js index ddb5002..3b600f5 100644 --- a/src/forwarder/mirror-node-client.js +++ b/src/forwarder/mirror-node-client.js @@ -60,6 +60,19 @@ class MirrorNodeClient { return this._get(`tokens/${tokenId}?${timestamp}`); } + /** + * Fetches information about an NFT by its token ID and serial ID. + * + * @param {string} tokenId the token ID to fetch. + * @param {number} serialId the serial ID of the NFT to fetch. + * @param {number} blockNumber + * @returns {Promise | null>} a `Promise` resolving to the token information or null if not found. + */ + async getNftByTokenIdAndNumber(tokenId, serialId, blockNumber) { + const timestamp = await this.getBlockQueryParam(blockNumber); + return this._get(`tokens/${tokenId}/nft/${serialId}?${timestamp}`); + } + /** * Get token relationship for an account. * @@ -99,6 +112,20 @@ class MirrorNodeClient { ); } + /** + * Fetches token allowances for a specific account, token, and operator. + * + * @param {string} accountId The owner's account ID. + * @param {string} tokenId The token ID. + * @param {string} operatorId The operator's account ID. + * @returns {Promise<{ allowances: { approved_for_all: boolean }[] } | null>} A `Promise` resolving to the approval. + */ + getAllowanceForNFT(accountId, tokenId, operatorId) { + return this._get( + `accounts/${accountId}/allowances/nfts?token.id=${tokenId}&account.id=${operatorId}` + ); + } + /** * Fetches account information by account ID, alias, or EVM address. * diff --git a/src/index.d.ts b/src/index.d.ts index db9a014..903b466 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -32,6 +32,23 @@ interface IMirrorNodeClient { */ getTokenById(tokenId: string, blockNumber: number): Promise | null>; + /** + * Get NFT by token id and serial id. + * + * Returns token NFT entity information given the token id and serial id. + * + * This method should call the Mirror Node API endpoint `GET /api/v1/tokens/{tokenId}/nft/{serialId}`. + * + * @param tokenId The ID of the token to return information for. + * @param serialId The serial id of the NFT. + * @param blockNumber + */ + getNftByTokenIdAndNumber( + tokenId: string, + serialId: number, + blockNumber: number + ): Promise | null>; + /** * Get token relationship for an account. * @@ -91,6 +108,26 @@ interface IMirrorNodeClient { }[]; } | null>; + /** + * Returns information for non-fungible token allowances for an account. + * + * NOTE: `blockNumber` is not yet included until we fix issue + * https://github.com/hashgraph/hedera-forking/issues/89. + * + * @param accountId Account alias or account id or evm address. + * @param tokenId The ID of the token to return information for. + * @param operatorId The ID of the operator to return information for. + */ + getAllowanceForNFT( + accountId: string, + tokenId: string, + operatorId: string + ): Promise<{ + allowances: { + approved_for_all: boolean; + }[]; + } | null>; + /** * Get account by alias, id, or evm address. * diff --git a/src/index.js b/src/index.js index 39e30ee..8291ab7 100644 --- a/src/index.js +++ b/src/index.js @@ -20,7 +20,7 @@ const { strict: assert } = require('assert'); const debug = require('util').debuglog('hts-forking'); const { ZERO_HEX_32_BYTE, toIntHex256 } = require('./utils'); -const { slotMapOf, packValues } = require('./slotmap'); +const { slotMapOf, packValues, persistentSlotMapOf } = require('./slotmap'); const { deployedBytecode } = require('../out/HtsSystemContract.sol/HtsSystemContract.json'); const HTSAddress = '0x0000000000000000000000000000000000000167'; @@ -138,11 +138,118 @@ async function getHtsStorageAt(address, requestedSlot, blockNumber, mirrorNodeCl return ret(ZERO_HEX_32_BYTE, `Token ${tokenId} not associated with ${accountId}`); } - const token = await mirrorNodeClient.getTokenById(tokenId, blockNumber); - if (token === null) return ret(ZERO_HEX_32_BYTE, `Token \`${tokenId}\` not found`); - const unresolvedValues = slotMapOf(token).load(nrequestedSlot); - if (unresolvedValues === undefined) - return ret(ZERO_HEX_32_BYTE, `Requested slot does not match any field slots`); + // Encoded `address(tokenId).getApproved(serialId)` slot + // slot(256) = `getApproved`selector(32) + padding(192) + serialId(32) + if ( + nrequestedSlot >> 32n === + 0x81812fc_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n + ) { + const serialId = parseInt(requestedSlot.slice(-8), 16); + const { spender } = + (await mirrorNodeClient.getNftByTokenIdAndNumber(tokenId, serialId, blockNumber)) ?? {}; + if (typeof spender !== 'string') + return ret( + ZERO_HEX_32_BYTE, + `NFT ${tokenId}#${serialId} is not approved to any address` + ); + const account = await mirrorNodeClient.getAccount(spender, blockNumber); + if (account === null) + return ret( + ZERO_HEX_32_BYTE, + `NFT ${tokenId}#${serialId} is approved to address \`${spender}\`, failed to get its EVM Alias` + ); + + return ret( + account.evm_address, + `NFT ${tokenId}#${serialId} is approved to ${account.evm_address}` + ); + } + + // Encoded `address(tokenId).ownerOf(serialId)` slot + // slot(256) = `ownerOf`selector(32) + padding(192) + serialId(32) + if ( + nrequestedSlot >> 32n === + 0x6352211e_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n + ) { + const serialId = parseInt(requestedSlot.slice(-8), 16); + const nft = (await mirrorNodeClient.getNftByTokenIdAndNumber( + tokenId, + serialId, + blockNumber + )) ?? { + account_id: null, + }; + if (typeof nft['account_id'] !== 'string') + return ret( + ZERO_HEX_32_BYTE, + `Failed to determine an owner of the NFT ${tokenId}#${serialId}` + ); + const account = await mirrorNodeClient.getAccount(`${nft['account_id']}`, blockNumber); + if (account === null) + return ret( + ZERO_HEX_32_BYTE, + `NFT ${tokenId}#${serialId} belongs to \`${nft['account_id']}\`, failed to get its EVM Alias` + ); + + return ret( + account.evm_address, + `NFT ${tokenId}#${serialId} is approved to ${account.evm_address}` + ); + } + + // Encoded `address(tokenId).isApprovedForAll(owner, operator)` slot + // slot(256) = `isApprovedForAll`selector(32) + padding(160) + ownerId(32) + operatorId(32) + if (nrequestedSlot >> 64n === 0xe985e9c5_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n) { + const operatorId = `0.0.${parseInt(requestedSlot.slice(-8), 16)}`; + const ownerId = `0.0.${parseInt(requestedSlot.slice(-16, -8), 16)}`; + const { allowances } = (await mirrorNodeClient.getAllowanceForNFT( + ownerId, + tokenId, + operatorId + )) ?? { allowances: [] }; + + if (allowances.length === 0) + return ret( + ZERO_HEX_32_BYTE, + `${tokenId}.isApprovedForAll(${ownerId},${operatorId}) not found` + ); + const value = allowances[0].approved_for_all ? 1 : 0; + return ret( + `0x${value.toString(16).padStart(64, '0')}`, + `Requested slot matches ${tokenId}.isApprovedForAll(${ownerId},${operatorId})` + ); + } + + // Encoded `address(tokenId).tokenURI(serialId)` slot + // slot(256) = `tokenURI`selector(32) + padding(192) + serialId(32) + if ( + nrequestedSlot >> 32n === + 0xc87b56dd_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000_0000n + ) { + const serialId = parseInt(requestedSlot.slice(-8), 16); + const { metadata } = (await mirrorNodeClient.getNftByTokenIdAndNumber( + tokenId, + serialId, + blockNumber + )) ?? { + metadata: null, + }; + if (typeof metadata !== 'string') + return ret( + ZERO_HEX_32_BYTE, + `Failed to get the metadata of the NFT ${tokenId}#${serialId}` + ); + persistentSlotMapOf(tokenId).store(nrequestedSlot, atob(metadata)); + } + let unresolvedValues = persistentSlotMapOf(tokenId).load(nrequestedSlot); + if (unresolvedValues === undefined) { + const token = await mirrorNodeClient.getTokenById(tokenId, blockNumber); + if (token === null) return ret(ZERO_HEX_32_BYTE, `Token \`${tokenId}\` not found`); + unresolvedValues = slotMapOf(token).load(nrequestedSlot); + + if (unresolvedValues === undefined) + return ret(ZERO_HEX_32_BYTE, `Requested slot does not match any field slots`); + } const values = await Promise.all( unresolvedValues.map(async ({ offset, path, value }) => ({ offset, diff --git a/src/slotmap.js b/src/slotmap.js index 32223bd..f7d8ab4 100644 --- a/src/slotmap.js +++ b/src/slotmap.js @@ -274,4 +274,60 @@ function slotMapOf(token) { return map; } -module.exports = { slotMapOf, packValues }; +/** + * Represents the value in the `PersistentStorage`. + * Each token has its own SlotMap. Any value can be assigned to this storage + * ad-hoc. It can be used for dynamically determined keys needed to be persistent. + * + * @typedef {Object} PersistentStorage + * @property {SlotMap} slotMap - The SlotMap instance. + * @property {string} target - The target string. + */ + +/** + * Represents the value in the `Storage`. + * Each token has its own SlotMap. Any value can be assigned to this storage + * ad-hoc. It can be used for dynamically determined keys needed to be persistent. + * + * @typedef {Object} PersistentSlotMap + * @property {function(bigint): Array<{ + * offset: number; + * value:Value; + * path:string; + * type:string; + * }>|undefined} load - Loads the value associated with a slot. + * @property {function(bigint, string): void} store - Stores a value in the slot map. + */ + +/** + * @type {Array} + */ +const persistentStorage = []; + +/** + * Slot map of token, but persistent, will not be removed between multiple separate requests. + * + * @param {string} target + * @returns {PersistentSlotMap} An object with `load` and `store` methods. + */ +function persistentSlotMapOf(target) { + const found = persistentStorage.filter(occupiedSlot => occupiedSlot.target === target); + const initialized = { + target, + slotMap: found.length > 0 && found[0] ? found[0].slotMap : new SlotMap(), + }; + persistentStorage.push(initialized); + return { + load: slot => initialized.slotMap.load(slot), + store: (slot, value) => + visit( + { label: 'value', slot: slot.toString(), type: 't_string_storage', offset: 0 }, + 0n, + { value }, + '', + initialized.slotMap + ), + }; +} + +module.exports = { packValues, persistentSlotMapOf, slotMapOf }; diff --git a/test/getHtsStorageAt.test.js b/test/getHtsStorageAt.test.js index b1283de..4392d60 100644 --- a/test/getHtsStorageAt.test.js +++ b/test/getHtsStorageAt.test.js @@ -48,6 +48,9 @@ describe('::getHtsStorageAt', function () { async getTokenRelationship(_idOrAliasOrEvmAddress, _tokenId) { throw Error('Not implemented'); }, + async getNftByTokenIdAndNumber(_tokenId, _serialId) { + throw Error('Not implemented'); + }, async getAccount(_idOrAliasOrEvmAddress) { throw Error('Not implemented'); }, @@ -57,6 +60,9 @@ describe('::getHtsStorageAt', function () { async getAllowanceForToken(_accountId, _tokenId, _spenderId) { throw Error('Not implemented'); }, + async getAllowanceForNFT(_accountId, _tokenId, _operatorId) { + throw Error('Not implemented'); + }, }; const slotsByLabel = storageLayout.storage.reduce( @@ -161,9 +167,54 @@ describe('::getHtsStorageAt', function () { expect(result).to.be.equal(`0x${'58d'.padStart(64, '0')}`); }); }); + /** + * Pads `accountId` to be encoded within a storage slot. + * + * @param {number} accountId The `accountId` to pad. + * @returns {string} + */ + const padAccountId = accountId => accountId.toString(16).padStart(8, '0'); + + /** + * Pads `accountId` to be encoded within a storage slot. + * + * @param {string|null} result Response returned by eth_getStorageAt + * @param {string} expectedString Expected string. + * @param {IMirrorNodeClient} client Mirror node client, to compare all slots taken by long strings + * @param {string} slot Base slot, to compare all slots taken by long string + * @param {string} address Address, to compare all slots taken by long string + */ + const expectCorrectString = async (result, expectedString, client, slot, address) => { + if (expectedString.length > 31) { + const len = (expectedString.length * 2 + 1).toString(16).padStart(2, '0'); + assert(result !== null); + expect(result.slice(2)).to.be.equal('0'.repeat(62) + len); + + const baseSlot = BigInt(keccak256('0x' + toIntHex256(slot))); + let value = ''; + for (let i = 0; i < (expectedString.length >> 5) + 1; i++) { + const result = await _getHtsStorageAt( + address, + `0x${(baseSlot + BigInt(i)).toString(16)}`, + client + ); + assert(result !== null); + value += result.slice(2); + } + const decoded = Buffer.from(value, 'hex') + .subarray(0, expectedString.length) + .toString('utf8'); + expect(decoded).to.be.equal(expectedString); + } else { + const value = Buffer.from(expectedString).toString('hex').padEnd(62, '0'); + const len = (expectedString.length * 2).toString(16).padStart(2, '0'); + assert(result !== null); + expect(result.slice(2)).to.be.equal(value + len); + } + }; Object.values(tokens) - .filter(t => ['USDC', 'MFCT'].includes(t.symbol)) + .filter(t => ['USDC', 'MFCT', 'CFNFTFF'].includes(t.symbol)) .forEach(({ symbol, address }) => { describe(`\`${symbol}(${address})\` token`, function () { const tokenResult = require(`./data/${symbol}/getToken.json`); @@ -197,31 +248,8 @@ describe('::getHtsStorageAt', function () { if (str.length > 31) { assert(this.test !== undefined); this.test.title += ' (large string)'; - const len = (str.length * 2 + 1).toString(16).padStart(2, '0'); - assert(result !== null); - expect(result.slice(2)).to.be.equal('0'.repeat(62) + len); - - const baseSlot = BigInt(keccak256('0x' + toIntHex256(slot))); - let value = ''; - for (let i = 0; i < (str.length >> 5) + 1; i++) { - const result = await _getHtsStorageAt( - address, - `0x${(baseSlot + BigInt(i)).toString(16)}`, - mirrorNodeClient - ); - assert(result !== null); - value += result.slice(2); - } - const decoded = Buffer.from(value, 'hex') - .subarray(0, str.length) - .toString('utf8'); - expect(decoded).to.be.equal(str); - } else { - const value = Buffer.from(str).toString('hex').padEnd(62, '0'); - const len = (str.length * 2).toString(16).padStart(2, '0'); - assert(result !== null); - expect(result.slice(2)).to.be.equal(value + len); } + await expectCorrectString(result, str, mirrorNodeClient, slot, address); }); }); @@ -237,14 +265,6 @@ describe('::getHtsStorageAt', function () { }); }); - /** - * Pads `accountId` to be encoded within a storage slot. - * - * @param {number} accountId The `accountId` to pad. - * @returns {string} - */ - const padAccountId = accountId => accountId.toString(16).padStart(8, '0'); - /**@type{{name: string, fn: IMirrorNodeClient['getBalanceOfToken']}[]}*/ ([ { name: 'balance is found', @@ -281,6 +301,56 @@ describe('::getHtsStorageAt', function () { }); }); + /**@type{{name: string, fn: IMirrorNodeClient['getTokenRelationship']}}*/ [ + { + name: 'token relationship is found', + fn: ( + /** @type {string} */ idOrAliasOrEvmAddress, + /** @type {string} */ _tid + ) => + require( + `./data/${symbol}/getTokenRelationship_${idOrAliasOrEvmAddress}` + ), + }, + { + name: 'token relationship is empty', + fn: (/** @type {string} */ _accountId, /** @type {string} */ _tid) => ({ + tokens: [], + }), + }, + { + name: 'token relationship is null', + fn: (/** @type {string} */ _accountId, /** @type {string} */ _tid) => null, + }, + ].forEach(({ name, fn: getTokenRelationship }) => { + const selector = id('isAssociated()').slice(0, 10); + const padding = '0'.repeat(24 * 2); + + it(`should get \`isAssociated(${selector})\` when '${name}'`, async function () { + const accountId = parseInt( + tokenResult.treasury_account_id.replace('0.0.', '') + ); + const slot = `${selector}${padding}${padAccountId(accountId)}`; + const result = await _getHtsStorageAt(address, slot, { + ...mirrorNodeClientStub, + getTokenRelationship, + }); + + const { tokens } = (await getTokenRelationship( + `0.0.${accountId}`, + tokenResult.token_id + )) ?? { tokens: [] }; + expect(result).to.be.equal( + tokens.length === 0 ? ZERO_HEX_32_BYTE : `0x${toIntHex256(1)}` + ); + }); + }); + }); + }); + Object.values(tokens) + .filter(t => ['USDC', 'MFCT'].includes(t.symbol)) + .forEach(({ symbol, address }) => { + describe(`\`${symbol}(${address})\` token (fungible)`, function () { /**@type{{name: string, fn: IMirrorNodeClient['getAllowanceForToken']}[]}*/ ([ { name: 'allowance is found', @@ -322,50 +392,108 @@ describe('::getHtsStorageAt', function () { ); }); }); - - /**@type{{name: string, fn: IMirrorNodeClient['getTokenRelationship']}}*/ [ - { - name: 'token relationship is found', - fn: ( - /** @type {string} */ idOrAliasOrEvmAddress, - /** @type {string} */ _tid - ) => - require( - `./data/${symbol}/getTokenRelationship_${idOrAliasOrEvmAddress}` - ), - }, - { - name: 'token relationship is empty', - fn: (/** @type {string} */ _accountId, /** @type {string} */ _tid) => ({ - tokens: [], - }), - }, - { - name: 'token relationship is null', - fn: (/** @type {string} */ _accountId, /** @type {string} */ _tid) => null, - }, - ].forEach(({ name, fn: getTokenRelationship }) => { - const selector = id('isAssociated()').slice(0, 10); - const padding = '0'.repeat(24 * 2); - - it(`should get \`isAssociated(${selector})\` when '${name}'`, async function () { - const accountId = parseInt( - tokenResult.treasury_account_id.replace('0.0.', '') - ); - const slot = `${selector}${padding}${padAccountId(accountId)}`; - const result = await _getHtsStorageAt(address, slot, { + }); + }); + Object.values(tokens) + .filter(t => t.symbol === 'CFNFTFF') + .forEach(({ symbol, address }) => { + describe(`\`${symbol}(${address})\` token (NFT)`, function () { + [1, 2].forEach(serialId => { + const nftResult = require( + `./data/${symbol}/getNonFungibleToken_${serialId}.json` + ); + it(`should get owner of serial id ${serialId}`, async function () { + const owner = nftResult['account_id']; + const fakeEVMAddress = `0x${toIntHex256(serialId)}`; + /** @type {IMirrorNodeClient} */ + const mirrorNodeClient = { ...mirrorNodeClientStub, - getTokenRelationship, - }); - - const { tokens } = (await getTokenRelationship( - `0.0.${accountId}`, - tokenResult.token_id - )) ?? { tokens: [] }; + getNftByTokenIdAndNumber(tokenId, _serial) { + // https://testnet.mirrornode.hedera.com/api/v1/tokens/0.0.4271533/nfts/1 + expect(tokenId).to.be.equal( + nftResult.token_id, + 'Invalid usage, provide the right address for token' + ); + return nftResult; + }, + getAccount(id) { + expect(id).to.be.equal( + owner, + `Failed to extract owner of serial id ${serialId}` + ); + return new Promise(resolve => + resolve({ + account: `0.0.${id}`, + evm_address: fakeEVMAddress, + }) + ); + }, + }; + const selector = id('ownerOf(uint256)').slice(0, 10); + const padding = '0'.repeat(64 - 8 - `${serialId}`.length); + const slot = `${selector}${padding}${serialId.toString(16)}`; + const result = await _getHtsStorageAt(address, slot, mirrorNodeClient); + expect(result).to.be.equal(fakeEVMAddress); + }); + it(`should confirm approval for serial id ${serialId}`, async function () { + const spender = nftResult['spender']; + const fakeEVMAddress = `0x${toIntHex256(serialId)}`; + /** @type {IMirrorNodeClient} */ + const mirrorNodeClient = { + ...mirrorNodeClientStub, + getNftByTokenIdAndNumber(tokenId, _serial) { + // https://testnet.mirrornode.hedera.com/api/v1/tokens/0.0.4271533/nfts/1 + expect(tokenId).to.be.equal( + nftResult.token_id, + 'Invalid usage, provide the right address for token' + ); + return nftResult; + }, + getAccount(id) { + expect(id).to.be.equal( + spender, + `Failed to extract spender of serial id ${serialId}` + ); + return new Promise(resolve => + resolve({ + account: `0.0.${id}`, + evm_address: fakeEVMAddress, + }) + ); + }, + }; + const selector = id('getApproved(uint256)').slice(0, 10); + const padding = '0'.repeat(64 - 8 - `${serialId}`.length); + const slot = `${selector}${padding}${serialId.toString(16)}`; + const result = await _getHtsStorageAt(address, slot, mirrorNodeClient); expect(result).to.be.equal( - tokens.length === 0 ? ZERO_HEX_32_BYTE : `0x${toIntHex256(1)}` + spender ? fakeEVMAddress : `0x${toIntHex256(0)}` ); }); + it(`should get storage for string field \`TokenURI\` for serial id ${serialId}`, async function () { + /** @type {IMirrorNodeClient} */ + const mirrorNodeClient = { + ...mirrorNodeClientStub, + getNftByTokenIdAndNumber(tokenId, _serial) { + // https://testnet.mirrornode.hedera.com/api/v1/tokens/0.0.4271533/nfts/1 + expect(tokenId).to.be.equal( + nftResult.token_id, + 'Invalid usage, provide the right address for token' + ); + return nftResult; + }, + }; + const selector = id('tokenURI(uint256)').slice(0, 10); + const padding = '0'.repeat(64 - 8 - `${serialId}`.length); + const slot = `${selector}${padding}${serialId.toString(16)}`; + const result = await _getHtsStorageAt(address, slot, mirrorNodeClient); + const str = atob(nftResult['metadata']); + if (str.length > 31) { + assert(this.test !== undefined); + this.test.title += ' (large string)'; + } + await expectCorrectString(result, str, mirrorNodeClient, slot, address); + }); }); }); }); diff --git a/test/scripts/json-rpc-mock.js b/test/scripts/json-rpc-mock.js index 932a8f8..f19f9b6 100755 --- a/test/scripts/json-rpc-mock.js +++ b/test/scripts/json-rpc-mock.js @@ -99,6 +99,11 @@ const mirrorNodeClient = { if (tokens[tokenId] === undefined) return null; return require(`../data/${tokens[tokenId].symbol}/getToken.json`); }, + async getNftByTokenIdAndNumber(tokenId, serialId) { + this.append('getNonFungibleToken', tokenId, serialId); + if (tokens[tokenId] === undefined) return null; + return require(`../data/${tokens[tokenId].symbol}/getNonFungibleToken_${serialId}.json`); + }, async getAccount(idOrAliasOrEvmAddress) { assert(!idOrAliasOrEvmAddress.startsWith('0x')); this.append('getAccount', idOrAliasOrEvmAddress); @@ -125,6 +130,15 @@ const mirrorNodeClient = { noAllowance ); }, + async getAllowanceForNFT(accountId, tokenId, operatorId) { + this.append('getAllowanceForNFT', accountId, tokenId, operatorId); + const noAllowance = { allowances: [] }; + if (tokens[tokenId] === undefined) return noAllowance; + return requireOrDefault( + `${tokens[tokenId].symbol}/getAllowanceForToken_${accountId}_${operatorId}.json`, + noAllowance + ); + }, async getTokenRelationship(idOrAliasOrEvmAddress, tokenId) { this.append('getTokenRelationship', idOrAliasOrEvmAddress, tokenId); const noTokens = { tokens: [] };