Solidity – How to Parse Event Args with Struct of Custom internalType in ethers.js

ethers.jseventssoliditystruct

Question: How does one parse the contents of a custom struct emitted as an indexed parameter with a contract event? Or, is it even possible to emit and then parse a custom struct from the event args?

I'm working on a generational NFT minting solidity contract which tracks each generation via a counter and a mapping of an internally defined struct GenInfo:

// Generation Counter
uint256 private s_generationCounter;

// Generation Struct
struct GenInfo {
    uint256 mintPrice;
    uint256 mintCap;
    uint256 supplyCap;
    uint256 startTime;
    string metadataCID;
}

// Mapping of generation number to GenInfo
mapping(uint256 => GenInfo) s_generationInfo;

An event is emitted with 2 indexed parameters when a new generation is created:

event NewGenerationCreated(
    uint256 indexed generation,
    GenInfo indexed genInfo
);

I am using ethers.js to submit the transaction and fetch the transaction receipt and would like to be able to decode the values within the indexed GenInfo struct emitted with the NewGenerationCreated event into a readable JavaScript object:

// createGenerationTxReceipt.events[0].args
[
  BigNumber { _hex: '0x01', _isBigNumber: true },
  {
    _isIndexed: true,
    hash: '0xc7ca7439fa64d98b58ae965bc052bcf154fcb3f7deec49e026bc7091c4fb4d72',
    constructor: [Function: Indexed] { isIndexed: [Function (anonymous)] }
  }
]

I can reconstruct the first param (uint256 generation) with

const newGeneration = creatGenerationTxReceipt.events[0].args[0].toNumber()

// newGeneration = 1

However, I'm having difficulty figuring out how to parse or decode the data format of the second parameter, my custom GenInfo struct, into a JavaScript object.

const genInfo = creatGenerationTxReceipt.events[0].args[1]

// genInfo - how to parse this into a JavaScript object?
{
  _isIndexed: true,
  hash: '0xc7ca7439fa64d98b58ae965bc052bcf154fcb3f7deec49e026bc7091c4fb4d72',
  constructor: [Function: Indexed] { isIndexed: [Function (anonymous)] }
}

I have tried using the interface associated with the contract to parse the logs but still just receive the same opaque object:

const data = creatGenerationTxReceipt.events[0].data
const topics = creatGenerationTxReceipt.events[0].topics

const logDescription = generationalNftContract.interface.parseLog({ data, topics })

// logDescripton.args
[
  BigNumber { _hex: '0x01', _isBigNumber: true },
  {
    _isIndexed: true,
    hash: '0x88f9f72724878230f7fe58eaf66cb38f2b5c3ba477c38faf1be850482903ec51',
    constructor: [Function]
  }
]

In the ABI of the contract, I can see that the NewGenerationCreated event has an input that matches the internalType of the struct:

{
      "anonymous": false,
      "inputs": [
        {
          "indexed": true,
          "internalType": "uint256",
          "name": "generation",
          "type": "uint256"
        },
        {
          "components": [
            {
              "internalType": "uint256",
              "name": "mintPrice",
              "type": "uint256"
            },
            {
              "internalType": "uint256",
              "name": "mintCap",
              "type": "uint256"
            },
            {
              "internalType": "uint256",
              "name": "supplyCap",
              "type": "uint256"
            },
            {
              "internalType": "uint256",
              "name": "startTime",
              "type": "uint256"
            },
            {
              "internalType": "string",
              "name": "metadataCID",
              "type": "string"
            }
          ],
          "indexed": true,
          "internalType": "struct GenerationalNftContract.GenInfo",
          "name": "genInfo",
          "type": "tuple"
        }
      ],
      "name": "NewGenerationCreated",
      "type": "event"
    }

This gives me the impression that there is a way to decode the output of the event args to reconstruct this input data from the struct GenerationalNftContract.GenInfo internalType to an object comprised of the struct's key/values by their internalType. However, in all my googling and reading through the ethers.js and solidity docs I'm unable to find a suitable method to parse this data.

Any help on this would be much appreciated!

Best Answer

This was an interesting question to hunt down the answer to. Thank you for providing so much detail in the question.

From Solidity's docs:

Indexed event parameters that are not value types, i.e. arrays and structs are not stored directly but instead a Keccak-256 hash of an encoding is stored.

More details on the exact specification of the encoding can be found there. This explains what the hashes you're seeing are. Making an event argument indexed puts it in the topics, which are 32 bytes each, so a struct wouldn't fit as-is. (I'm not certain why you're receiving two different hash values when looking at the tx receipt directly versus using parseLog() though.) This may already be obvious to you, but you will not be able to parse the arguments from a hash of the struct. If you have an idea of what the arguments are, you could reconstruct the hash in JavaScript, keccak256 hash it, and then compare the hash generated in the JavaScript against the hash returned in the event. Note that the last element in the struct, the string makes the encoding a bit more complex.

There is still hope, however. The key word in the quote above is indexed. Event data is only placed in topics if it's indexed. Otherwise, it stays in data. This means that if you do not index the GenInfo struct, it will be emitted in the event without being hashed first. The downside is that if there are other unindexed arguments, you'll have to decode the data and figure out where the struct starts and ends. This should be easier in your case, as the struct would be the only unindexed argument, however.

The docs also talk about the tradeoff of putting a dynamic type like a struct in topics or data:

For dynamic-length types, application developers face a trade-off between fast search for predetermined values (if the argument is indexed) and legibility of arbitrary values (which requires that the arguments not be indexed). Developers may overcome this tradeoff and achieve both efficient search and arbitrary legibility by defining events with two arguments — one indexed, one not — intended to hold the same value.

In short, if being able to parse the data is critical, you should leave the struct unindexed. If being able to search for the event is critical and the hash can be reconstructed and verified off-chain, you should index it. If both are critical, you should emit the struct both indexed and unindexed.

Related Topic