Storage Operations
Overview
Contract storage can be read directly using storage pointers, providing low-level access to contract state without invoking contract functions. The provider exposes the getStorageAt() method to retrieve raw storage values at specific pointer locations. This capability is useful for debugging, state verification, building block explorers, and advanced integrations that require direct access to contract internals.
Reading Contract Storage
Using getStorageAt()
The getStorageAt() method retrieves a value from a contract's storage at a specific pointer. The method accepts the contract address, a storage pointer as bigint or base64 string, an optional flag to include Merkle proofs, and an optional block height for historical queries.
The returned StoredValue object contains the raw storage data and optional proof information for verification. This low-level access is useful for debugging, building explorers, or accessing storage slots not exposed through the contract's public interface.
Method Signature
async getStorageAt(
address: string | Address, // Contract address
rawPointer: bigint | string, // Storage pointer (bigint or base64)
proofs?: boolean, // Include proofs (default: true)
height?: BigNumberish // Optional block height
): Promise<StoredValue>StoredValue Structure
interface StoredValue {
pointer: bigint; // Storage slot pointer
value: Uint8Array; // Stored value as Uint8Array
height: bigint; // Block height of the value
proofs: string[]; // Merkle proofs for verification
}Basic Storage Query
The following example demonstrates retrieving a storage value using a numeric pointer. The returned object contains the pointer, raw value as bytes, and the block height at which the value was read.
import { JSONRpcProvider } from 'opnet';
import { networks, toHex } from '@btc-vision/bitcoin';
const network = networks.regtest;
const provider = new JSONRpcProvider({ url: 'https://regtest.opnet.org', network });
const contractAddress = 'bc1p...contract-address...';
const pointer = 123456789n; // Storage slot as bigint
const storage = await provider.getStorageAt(contractAddress, pointer);
console.log('Storage Value:');
console.log(' Pointer:', storage.pointer);
console.log(' Value:', toHex(storage.value));
console.log(' Height:', storage.height);With Base64 String Pointer
Storage pointers can also be provided as base64-encoded strings, which is useful when working with pointer values obtained from external sources or APIs that return pointers in this format.
// Pointer can be provided as base64 string
const base64Pointer = 'EXLK/QhEQMI5d9DrthLvozT+UcDQ7WuSPaz7g8GV3AQ=';
const storage = await provider.getStorageAt(
contractAddress,
base64Pointer
);
console.log('Value:', toHex(storage.value));With/Without Proofs
By default, storage queries include Merkle proofs for cryptographic verification of the returned value. Setting the proofs parameter to false skips proof generation, resulting in faster responses when verification is not required.
// Request proofs for verification (default: true)
const storage = await provider.getStorageAt(
contractAddress,
pointer,
true // Include proofs
);
console.log('Proofs:', storage.proofs.length);
for (const proof of storage.proofs) {
console.log(' Proof:', proof
}
// Skip proofs for faster response
const storage2 = await provider.getStorageAt(
contractAddress,
pointer,
false // No proofs
);
console.log('Value:', toHex(storage2.value));
// storage2.proofs will be emptyAt Specific Height
Storage values can be retrieved at a specific block height, enabling historical state analysis and point-in-time queries. The returned height confirms the block at which the value was read.
// Read historical storage at specific block height
const historicalStorage = await provider.getStorageAt(
contractAddress,
pointer,
true, // Include proofs
100000n // Block height
);
console.log('Value at block 100000:', toHex(historicalStorage.value));
console.log('Confirmed at height:', historicalStorage.height);Decoding Stored Values
Raw storage values are returned as byte arrays and require decoding based on the expected data type. Common storage types include integers, addresses, and strings, each requiring different decoding logic. The following examples demonstrate decoding storage values into their typed representations.
Decoding Uint256
Integer storage values are typically stored as 32-byte big-endian unsigned integers. The following example demonstrates decoding raw bytes into a bigint value.
function decodeUint256(value: Uint8Array): bigint {
// Stored values are typically 32 bytes
if (value.length === 0) return 0n;
// Read as big-endian unsigned integer
let result = 0n;
for (const byte of value) {
result = (result << 8n) | BigInt(byte);
}
return result;
}
// Usage
const storage = await provider.getStorageAt(contractAddress, pointer);
const balance = decodeUint256(storage.value);
console.log('Balance:', balance);Decoding Address
Address values are stored as byte sequences and can be extracted from the raw storage data. The following example demonstrates decoding storage bytes into an Address object.
import { Address } from '@btc-vision/bitcoin';
function decodeAddress(value: Uint8Array): Address | null {
if (value.length < 20) return null;
// Extract address bytes
const addressBytes = value.slice(-20);
return Address.fromBuffer(addressBytes);
}
// Usage
const storage = await provider.getStorageAt(contractAddress, ownerSlot);
const owner = decodeAddress(storage.value);
console.log('Owner:', owner?.toHex());Reading Map Value
Contract mappings store values at computed storage slots derived from the base slot and key. The pointer is calculated by hashing the concatenation of the key and slot using keccak256. The following example demonstrates reading a value from a balances mapping by computing its storage pointer.
import { keccak256 } from 'ethers';
import { fromHex } from '@btc-vision/bitcoin';
function getMapPointer(mapSlot: bigint, key: Uint8Array): bigint {
// Standard mapping pointer calculation
const slotBuffer = new Uint8Array(32);
new DataView(slotBuffer.buffer).setBigUint64(24, mapSlot);
const data = new Uint8Array([...key, ...slotBuffer]);
const hash = keccak256(data);
return BigInt(hash);
}
// Usage: Read balances[address]
const balancesSlot = 0n; // Storage slot of balances mapping
const userKey = fromHex(userAddress.slice(2));
const pointer = getMapPointer(balancesSlot, userKey);
const storage = await provider.getStorageAt(contractAddress, pointer);
console.log('User balance:', decodeUint256(storage.value));Verifying Storage Proofs
Storage queries return Merkle proofs that enable cryptographic verification of the returned value without trusting the provider. Verifying these proofs confirms that the storage value is consistent with the blockchain state at the specified block height, providing strong integrity guarantees for critical operations.
Verify Storage Proof
The following example demonstrates verifying a storage value by comparing it against an expected value and confirming that proofs are present. For full cryptographic verification, additional logic would validate the Merkle proof against the block's state root.
async function verifyStorageWithProof(
provider: JSONRpcProvider,
contractAddress: string,
pointer: bigint,
expectedValue: Uint8Array
): Promise<boolean> {
const storage = await provider.getStorageAt(
contractAddress,
pointer,
true // Request proofs
);
// Compare value
if (storage.value.length !== expectedValue.length ||
!storage.value.every((b, i) => b === expectedValue[i])) {
return false;
}
// Verify proofs exist
if (storage.proofs.length === 0) {
console.warn('No proofs provided');
return false;
}
// Additional proof verification would go here
// (depends on specific proof format)
return true;
}
// Usage
const verified = await verifyStorageWithProof(
provider,
contractAddress,
pointer,
expectedBytes
);
console.log('Storage verified:', verified);Compare Storage Values
Comparing storage values at different block heights enables detection of state changes over time. The following example queries storage at two block heights and compares the values to determine if a change occurred within the specified range.
async function hasStorageChanged(
provider: JSONRpcProvider,
contractAddress: string,
pointer: bigint,
fromHeight: bigint,
toHeight: bigint
): Promise<boolean> {
const [oldStorage, newStorage] = await Promise.all([
provider.getStorageAt(contractAddress, pointer, false, fromHeight),
provider.getStorageAt(contractAddress, pointer, false, toHeight),
]);
return oldStorage.value.length !== newStorage.value.length ||
!oldStorage.value.every((b, i) => b === newStorage.value[i]);
}
// Usage
const changed = await hasStorageChanged(
provider,
contractAddress,
pointer,
100000n,
100100n
);
console.log('Storage changed:', changed);Reading Multiple Storage Slots
Reading multiple storage slots individually results in separate network requests for each query. Using concurrent queries with Promise.all() improves efficiency by fetching multiple values in parallel, reducing overall latency when accessing several storage locations from the same contract.
Read Multiple Storage Slots
async function getMultipleStorage(
provider: JSONRpcProvider,
contractAddress: string,
pointers: bigint[]
): Promise<Map<bigint, Uint8Array>> {
const results = new Map<bigint, Uint8Array>();
// Read in parallel
const promises = pointers.map(pointer =>
provider.getStorageAt(contractAddress, pointer, false)
);
const storageValues = await Promise.all(promises);
for (let i = 0; i < pointers.length; i++) {
results.set(pointers[i], storageValues[i].value);
}
return results;
}
// Usage
const pointers = [0n, 1n, 2n, 3n]; // Multiple storage slots
const values = await getMultipleStorage(provider, contractAddress, pointers);
for (const [pointer, value] of values) {
console.log(`Slot ${pointer}: ${toHex(value)}`);
}Read Storage Range
async function getStorageRange(
provider: JSONRpcProvider,
contractAddress: string,
startPointer: bigint,
count: number
): Promise<StoredValue[]> {
const pointers = Array.from(
{ length: count },
(_, i) => startPointer + BigInt(i)
);
const promises = pointers.map(pointer =>
provider.getStorageAt(contractAddress, pointer, false)
);
return Promise.all(promises);
}
// Usage
const storageRange = await getStorageRange(provider, contractAddress, 0n, 10);
console.log('First 10 storage slots:');
for (const storage of storageRange) {
console.log(` ${storage.pointer}: ${toHex(storage.value)}`);
}Complete Storage Service
class StorageService {
constructor(private provider: JSONRpcProvider) {}
async get(
contract: string,
pointer: bigint,
options?: { proofs?: boolean; height?: bigint }
): Promise<StoredValue> {
return this.provider.getStorageAt(
contract,
pointer,
options?.proofs ?? false,
options?.height
);
}
async getValue(contract: string, pointer: bigint): Promise<Uint8Array> {
const storage = await this.get(contract, pointer);
return storage.value;
}
async getUint256(contract: string, pointer: bigint): Promise<bigint> {
const value = await this.getValue(contract, pointer);
return this.decodeUint256(value);
}
async getMultiple(
contract: string,
pointers: bigint[]
): Promise<Map<bigint, Uint8Array>> {
const results = new Map<bigint, Uint8Array>();
const storageValues = await Promise.all(
pointers.map(p => this.get(contract, p))
);
for (let i = 0; i < pointers.length; i++) {
results.set(pointers[i], storageValues[i].value);
}
return results;
}
async hasValue(contract: string, pointer: bigint): Promise<boolean> {
const value = await this.getValue(contract, pointer);
return value.length > 0 && !value.every(b => b === 0);
}
async compare(
contract: string,
pointer: bigint,
height1: bigint,
height2: bigint
): Promise<{
changed: boolean;
value1: Uint8Array;
value2: Uint8Array;
}> {
const [s1, s2] = await Promise.all([
this.get(contract, pointer, { height: height1 }),
this.get(contract, pointer, { height: height2 }),
]);
return {
changed: s1.value.length !== s2.value.length ||
!s1.value.every((b, i) => b === s2.value[i]),
value1: s1.value,
value2: s2.value,
};
}
private decodeUint256(value: Uint8Array): bigint {
if (value.length === 0) return 0n;
let result = 0n;
for (const byte of value) {
result = (result << 8n) | BigInt(byte);
}
return result;
}
}
// Usage
const storageService = new StorageService(provider);
// Read single value
const balance = await storageService.getUint256(contractAddress, balanceSlot);
console.log('Balance:', balance);
// Read multiple
const values = await storageService.getMultiple(contractAddress, [0n, 1n, 2n]);
for (const [slot, value] of values) {
console.log(`Slot ${slot}:`, toHex(value));
}
// Compare historical values
const comparison = await storageService.compare(
contractAddress,
pointer,
100000n,
100500n
);
console.log('Value changed:', comparison.changed);Common Storage Patterns
ERC-20 Style Storage Layout
Token contracts follow common storage layouts where standard properties occupy predictable slots. The following example defines common slot positions for ERC-20-like contracts and demonstrates fetching multiple token properties concurrently using parallel queries.
// Common storage slots for ERC-20-like contracts
const STORAGE_SLOTS = {
NAME: 0n, // string name
SYMBOL: 1n, // string symbol
DECIMALS: 2n, // uint8 decimals
TOTAL_SUPPLY: 3n, // uint256 totalSupply
BALANCES: 4n, // mapping(address => uint256)
ALLOWANCES: 5n, // mapping(address => mapping(address => uint256))
};
async function getTokenInfo(
provider: JSONRpcProvider,
tokenAddress: string
): Promise<{
totalSupply: bigint;
decimals: bigint;
}> {
const storageService = new StorageService(provider);
const [totalSupply, decimals] = await Promise.all([
storageService.getUint256(tokenAddress, STORAGE_SLOTS.TOTAL_SUPPLY),
storageService.getUint256(tokenAddress, STORAGE_SLOTS.DECIMALS),
]);
return { totalSupply, decimals };
}Best Practices
- Use Pointers Correctly: Ensure the correct storage slot or pointer is used for the data being accessed. Incorrect pointers return unrelated or empty values without error.
- Skip Proofs When Unnecessary: Set proofs to false for faster queries when cryptographic verification is not required. Proof generation adds overhead to each request.
- Batch Requests: Read multiple storage values in parallel using Promise.all() to reduce overall latency when accessing several slots.
- Handle Empty Values: Storage slots may be empty or zero-filled if never written. Check for empty arrays or zero values before decoding.
- Historical Queries: Use the height parameter for point-in-time reads when analyzing past state or debugging historical transactions.
- Cache Results: Storage values at specific block heights are immutable and can be cached indefinitely to reduce redundant queries.