Ethereum: Autogenerated TypeScript classes to read and write* contract's private state variables

Ethereum: Autogenerated TypeScript classes to read and write* contract's private state variables

ยท

5 min read

* to write in the local and forked development network

In the previous article, we looked into how the EVM storage is arranged and how we can access all the contract's data relatively easily when the contract is validated or we have the source code. To simplify it even further, there is the storage handler in the dequanto library and the 0xweb CLI tool. I've published the storage demo repository at github/examples-storage, which is used for the 0xweb CI pipeline, and I'll go through the example in this article.

We start with the contract's class generation:

$ npm i 0xweb -g
$ 0xweb init --hardhat
$ 0xweb i 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --name USDC --chain eth

This fetches the contract sources from etherscan, detects it as a proxy, and fetches the implementation at 0xa2327a938febf5fec13bacfb16ae10ecbc4cbdcf. After the source code and the ABI are present 0xweb generates the TypeScript class for the contract: USDC.ts - that, beyond the read and write methods of the contract, also has the storage handler to read the state variables and to overwrite state variables data in the hardhat environment.

EVM Storage Forking

To play around with the storage we can fork the Ethereum Mainnet storage with a hardhat development server. Hardhat launches the local chain that "inherits" the complete state. Earlier I thought, this process was very time and resource-consuming, but in reality, it is a simple RPC proxy. When you call some method of the contract, for instance balanceOf of the USDC contract and the local server doesn't have any code yet for the address, it fetches this from the forked RPC endpoint with getCode, when the code gets executed and it reads the balances[account] the slot location is not yet present locally, so it will fetch the data with single getStorageAt to get the slot and resume contract execution. Hardhat allows you to overwrite the local storage. That is why forking is a great feature of developing, testing, and performing on-chain research.

Once left to mention - forking per default gets the latest block number and all proxy requests are made with the block number, but you can specify any block number you want to fork the remote chain from and perform operations as if the contract has all the state of that point of time. For this reason, when forking you should have an archive node. But, if you use latest block number for forking and don't have access to the archive node, your requests will still work for some time (latest is fetched for a specific number), depending on how quickly your node clears the state - for geth this is 128 blocks, which is around 27 minutes.

Run demo actions

I use atma and atma-utest to execute typescript actions. The actions you can see in actions/usdc.act.ts

[1] Create accounts

atma act actions/usdc.act.ts -q "create-accounts"

Prepares two accounts, which we will use to fund, overwrite and transfer balances.

[2] Overwrite balance for account "foo"

atma act actions/usdc.act.ts -q "set-balance"

The generated TypeScript class has the storage field with autogenerated getters for all state variables

Foreword: Storage readers

let usdc = new USDC();
let amount: bigint = await usdc.storage.totalSupply_();

This reads the internal variable directly from the storage of the USDC contract:

contract FiatTokenV1 is AbstractFiatTokenV1, Ownable, Pausable, Blacklistable {
    // ...
    mapping(address => uint256) internal balances;
    mapping(address => mapping(address => uint256)) internal allowed;
    uint256 internal totalSupply_ = 0;
    // ...
}

Though there is an external method totalSupply() in the contract, but it depends on the contract developers if they decide to present getters manually or set variable visibility to public. That's why the contract may not have those getters, but with the storage handler, we can read all state variables of the contract.

So, after we generate the storage handler class, it contains methods to get all contract variables. The methods are strongly typed - with arguments and correct return types, but there is a more generic method storage.$get(accessorPath) to get variable chains like the JSON's paths, e.g. myContract.storage.$get('foo.bar.qux')

Consider such a contract:

contract Foo {
    struct Position { 
        address owner;
        uint amount
    }
    Position[] positions;
}

We can read for example the address of the owner on the index 1 with:

let foo = new Foo();
let owner = await foo.storage.$get('positions[1].owner');
// with generated method we would get the complete position struct
let position = await foo.storage.positions(1);
// OR with $get
let position = await foo.storage.$get('positions[1]')
// the position variable has the interface of IPosition
interface IPosition {
    owner: TAddress
    amount: bigint 
}

Setters

Writing to the storage works only in the development environment and it can be done only via the "$set" method. Now, back to our action set-balance โ€” we must set the new amount to the storage slot of the user balance.

let amount = 50n * 10n**6n // 50 USDC
let address = foo.address;
await usdc.storage.$set(`balances["${address}"]`, amount);

[3] Transfer balance

That's it - now we can use the newly updated balance, and transfer the tokens to the account "bar".

let tx = await usdc.transfer(foo as ChainAccount, bar.address, fooBalance);
let receipt = await tx.wait();

Other hardhat development methods:

๐Ÿ Happy coding

Did you find this article valuable?

Support Alex Kit by becoming a sponsor. Any amount is appreciated!

ย