Skip to content

Commit

Permalink
feat: implement nft support for the json rpc api library and js mock …
Browse files Browse the repository at this point in the history
…server (#80)

Signed-off-by: Mariusz Jasuwienas <[email protected]>
  • Loading branch information
arianejasuwienas committed Jan 8, 2025
1 parent baf5283 commit d6c17f3
Show file tree
Hide file tree
Showing 6 changed files with 449 additions and 80 deletions.
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 getNftByTokenIdAndNumber(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
*/
getNftByTokenIdAndNumber(
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.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,
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
* 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 = [];

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

0 comments on commit d6c17f3

Please sign in to comment.