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 3 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
119 changes: 113 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, persistentSlotMapOf } = require('./slotmap');
const { deployedBytecode } = require('../out/HtsSystemContract.sol/HtsSystemContract.json');

const HTSAddress = '0x0000000000000000000000000000000000000167';
Expand Down Expand Up @@ -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.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}`
);
persistentSlotMapOf(tokenId).store(nrequestedSlot, atob(metadata));
}
let unresolvedValues = persistentSlotMapOf(tokenId).load(nrequestedSlot);
Copy link
Contributor

Choose a reason for hiding this comment

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

moreover, not sure this works as intended, since it's going to always write when asked for token uri.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When querying the first slot holding the tokenUri, it will store the values for all slots containing this single string.

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
58 changes: 57 additions & 1 deletion src/slotmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.
*
* @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<PersistentStorage>}
*/
const persistentStorage = [];
acuarica marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

@acuarica acuarica Jan 8, 2025

Choose a reason for hiding this comment

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

This is actually a Map, whose keys are addresses (and blockNumber, see below), right? We can use a Map or object to be more explicit and avoid doing the lookup below.

Copy link
Contributor

Choose a reason for hiding this comment

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

I would move this global state to index.js.


/**
* 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);
Copy link
Contributor

Choose a reason for hiding this comment

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

what about blockNumber? The key should be target and blockNumber because the user can change the blockNumber.

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 };
Loading