Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [Hardhat] implement nft support for the json rpc api library and js mock … #173

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,7 @@ jobs:
run: node test/scripts/json-rpc-mock.js &

- name: Run Forge tests using JSON-RPC mock server for storage emulation (with forking)
# Remove no-match-contract when the support for ERC721 is implemented in the JSON RPC mock server (#80 task)
run: forge test -vvv --fork-url http://localhost:7546 --no-storage-caching --no-match-contract ^ERC721TokenTest$
run: forge test -vvv --fork-url http://localhost:7546 --no-storage-caching

foundry-example-tests:
name: 'Foundry example tests w/mainnet'
Expand Down
27 changes: 27 additions & 0 deletions src/forwarder/mirror-node-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown> | null>} a `Promise` resolving to the token information or null if not found.
*/
async getNftByTokenIdAndSerial(tokenId, serialId, blockNumber) {
const timestamp = await this.getBlockQueryParam(blockNumber);
return this._get(`tokens/${tokenId}/nft/${serialId}?${timestamp}`);
}

/**
* Get token relationship for an account.
*
Expand Down Expand Up @@ -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.
*
Expand Down
37 changes: 37 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ interface IMirrorNodeClient {
*/
getTokenById(tokenId: string, blockNumber: number): Promise<Record<string, unknown> | 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
*/
getNftByTokenIdAndSerial(
tokenId: string,
serialId: number,
blockNumber: number
): Promise<Record<string, unknown> | null>;

/**
* Get token relationship for an account.
*
Expand Down Expand Up @@ -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.
*
Expand Down
124 changes: 118 additions & 6 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, PersistentStorageMap } = require('./slotmap');
const { deployedBytecode } = require('../out/HtsSystemContract.sol/HtsSystemContract.json');

const HTSAddress = '0x0000000000000000000000000000000000000167';
Expand All @@ -37,6 +37,11 @@ function getHtsCode() {
return deployedBytecode.object;
}

/**
* Slot map of tokens, but persistent, will not be removed between multiple separate requests.
*/
const persistentStorage = new PersistentStorageMap();

/**
* @param {string} address
* @param {string} requestedSlot
Expand Down Expand Up @@ -138,11 +143,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.getNftByTokenIdAndSerial(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.getNftByTokenIdAndSerial(
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.getNftByTokenIdAndSerial(
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}`
);
persistentStorage.store(tokenId, blockNumber, nrequestedSlot, atob(metadata));
}
let unresolvedValues = persistentStorage.load(tokenId, blockNumber, 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,
Expand Down
52 changes: 51 additions & 1 deletion src/slotmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,4 +274,54 @@ function slotMapOf(token) {
return map;
}

module.exports = { slotMapOf, packValues };
/**
* Represents the value in the persistent storage.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is some information in the PR description that would be really useful here. In particular 1. of section "The reason I've used persistent storage here" and section "Why it might be an issue".

We should include that info as a JS doc here.

* Each token has its own SlotMap. Any value can be assigned to this storage
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Each token has its own SlotMap. Any value can be assigned to this storage
* Each token and `blockNumber` 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.
*/
class PersistentStorageMap {
constructor() {
/** @type {Map<string, SlotMap>} */
this._map = new Map();
}

/**
* @param {string} tokenId
* @param {number} blockNumber
*/
_init(tokenId, blockNumber) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_init(tokenId, blockNumber) {
_getSlotMap(tokenId, blockNumber) {

const key = `${tokenId}:${blockNumber}`;
const initialized = this._map.get(key) || new SlotMap();
if (!this._map.has(key)) {
this._map.set(key, initialized);
}
return initialized;
Comment on lines +294 to +298
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to avoid the double guard

Suggested change
const initialized = this._map.get(key) || new SlotMap();
if (!this._map.has(key)) {
this._map.set(key, initialized);
}
return initialized;
let slotMap = this._map.get(key);
if (slotMap === undefined) {
slotMap = new SlotMap();
this._map.set(key, slotMap);
}
return slotMap;

}

/**
* @param {string} tokenId
* @param {number} blockNumber
* @param {bigint} slot
* @param {Value} value
*/
store(tokenId, blockNumber, slot, value) {
visit(
{ label: 'value', slot: slot.toString(), type: 't_string_storage', offset: 0 },
0n,
{ value },
'',
this._init(tokenId, blockNumber)
);
}

/**
* @param {string} tokenId
* @param {number} blockNumber
* @param {bigint} slot
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's add return type for clarity.

load(tokenId, blockNumber, slot) {
return this._init(tokenId, blockNumber).load(slot);
}
}

module.exports = { packValues, slotMapOf, PersistentStorageMap };
Loading