Simulating a Call
Overview
Simulation is the first phase of any contract interaction on OP_NET. During simulation, contract code executes within a virtual environment against the current blockchain state, producing a complete execution report without broadcasting a transaction or consuming any Bitcoin.
The simulation captures return values, gas consumption, emitted events, and potential failures, all encapsulated in a CallResult object. This mechanism enables validation and cost estimation prior to committing resources to an on-chain operation. For read-only functions such as balance queries or metadata retrieval, simulation is the only required step since no state modification occurs.
Read-Only Method Calls
Read-only methods retrieve data from the contract without modifying blockchain state. These calls require only the simulation phase, as no transaction needs to be broadcast. Common examples include querying token balances, retrieving metadata, checking allowances, and reading contract configuration.
The response from the node is automatically decoded based on the contract's ABI definition. For detailed information on accessing decoded values, refer to the Decoded Outputs section.
// Read token metadata
const name = await token.name();
const symbol = await token.symbol();
const decimals = await token.decimals();
const totalSupply = await token.totalSupply();
// Access results
console.log('Name:', name.properties.name);
console.log('Symbol:', symbol.properties.symbol);
console.log('Decimals:', decimals.properties.decimals);
console.log('Total Supply:', totalSupply.properties.totalSupply);Batching operations can significantly improve efficiency.
// Parallel reads are efficient
const [name, symbol, decimals, totalSupply, balance] = await Promise.all([
token.name(),
token.symbol(),
token.decimals(),
token.totalSupply(),
token.balanceOf(myAddress),
]);Decoded Outputs
When a contract method returns data, the raw response from the node is a binary-encoded payload. The library automatically decodes this payload based on the method's output definition in the ABI, transforming it into a structured TypeScript object. This decoded data is accessible through the properties property of the CallResult.
The structure of properties matches the return type defined in the contract interface. TypeScript generics ensure compile-time type safety, providing full IntelliSense support for property names and types.
The decoding process handles all supported ABI data types. If the ABI definition does not match the actual response, the decoding will fail and an error will be thrown.
// The balanceOf method returns { balance: bigint }
const result = await token.balanceOf(address);
console.log('Balance:', result.properties.balance);
// The name method returns { name: string }
const nameResult = await token.name();
console.log('Token name:', nameResult.properties.name);
// The decimals method returns { decimals: number }
const decimalsResult = await token.decimals();
console.log('Decimals:', decimalsResult.properties.decimals);For methods that return multiple values, each value is accessible as a separate property.
// A method returning multiple values
const result = await contract.getPoolInfo();
console.log('Reserve A:', result.properties.reserveA);
console.log('Reserve B:', result.properties.reserveB);
console.log('Total supply:', result.properties.totalSupply);Handling Reverts
Contract calls may fail for various reasons. When a simulation detects that a call would fail, the CallResult captures the failure reason in the revert property. Checking for reverts before accessing return values or sending transactions prevents runtime errors and provides clear feedback about why an operation cannot proceed.
const result = await token.transfer(recipient, amount, new Uint8Array(0));
if (result.revert) {
// Call would fail
console.error('Transfer failed:', result.revert);
return;
}
// Safe to proceed
console.log('Transfer would succeed');
const tx = await result.sendTransaction(params);Common revert reasons
| Revert Message | Cause |
|---|---|
| Insufficient balance | Sender doesn't have enough tokens. |
| Insufficient allowance | Spender not approved for amount. |
| Invalid address | Zero address or invalid format. |
| Overflow | Amount exceeds maximum. |
| Paused | Contract is paused. |
| Not authorized | Caller lacks permission. |
| Invalid amount | Amount is zero or invalid. |
Historical Queries
By default, simulations execute against the current blockchain state. However, the library supports querying contract state at a specific block height, enabling historical data retrieval and state analysis at past points in time. This capability is useful for auditing, debugging, and building applications that require historical context.
// Set simulation height before calling
token.setSimulatedHeight(12345n);
// This query executes as of block 12345
const historicalBalance = await token.balanceOf(address);
// Reset to current height
token.setSimulatedHeight(undefined);Using Access Lists
Access lists record which storage slots are accessed during contract execution. When provided to subsequent calls, they allow the node to prepare the required storage data in advance, potentially reducing gas consumption.
const result = await token.transfer(recipient, amount, new Uint8Array(0));
// Access list shows storage accessed
console.log('Access list:', result.accessList);
// Use access list to optimize future calls
token.setAccessList(result.accessList);
const optimizedResult = await token.transfer(recipient, amount, new Uint8Array(0));Transaction Details for Simulation
Certain contract operations require knowledge of the Bitcoin transaction structure to execute correctly. For example, an NFT claim contract may need to verify that a payment was sent to a treasury address, or a swap contract may need to validate specific input UTXOs.
The setTransactionDetails() method allows simulation of these scenarios by providing the contract with information about the transaction inputs and outputs that will accompany the contract call.
Understanding Transaction Details
When a contract executes on-chain, it has access to the full Bitcoin transaction context, including all inputs and outputs. During simulation, this context does not exist since no actual transaction is being created. The setTransactionDetails() method bridges this gap by allowing the simulated transaction structure to be defined, enabling the contract to perform its validation logic as if a real transaction were being processed.
Related Interfaces
Transaction details are defined using the ParsedSimulatedTransaction interface, which contains arrays of inputs and outputs.
interface ParsedSimulatedTransaction {
readonly inputs: StrippedTransactionInput[];
readonly outputs: StrippedTransactionOutput[];
}Each input represents a UTXO being spent in the transaction.
interface StrippedTransactionInput {
readonly txId: Uint8Array;
readonly outputIndex: number;
readonly scriptSig: Uint8Array;
readonly witnesses: Uint8Array[];
readonly flags: number;
readonly coinbase?: Uint8Array;
}Each output represents a destination for funds in the transaction.
interface StrippedTransactionOutput {
readonly value: bigint;
readonly index: number; // Output index (0 is reserved)
readonly flags: number;
readonly scriptPubKey?: Uint8Array;
readonly to?: string; // P2OP/P2TR address string
}Transaction Flags
Flags indicate which optional fields are present in inputs and outputs. The library provides enums for these flags.
// Required imports when using input and output flags
import { TransactionInputFlags, TransactionOutputFlags } from 'opnet';
// Output flags definition
enum TransactionOutputFlags {
hasScriptPubKey = 1, // Output contains raw scriptPubKey
hasTo = 2, // Output contains address string
}
// Input flags definition
enum TransactionInputFlags {
hasWitnesses = 1, // Input contains witness data
hasCoinbase = 2, // Input is a coinbase input
}Simulating with Extra Outputs
The most common use case is simulating a contract call that verifies payment to a specific address. For example, when claiming an NFT that requires a treasury payment.
import { TransactionOutputFlags } from 'opnet';
const treasuryAddress: string = 'bcrt1q...';
// Define the payment output the contract expects to see
contract.setTransactionDetails({
inputs: [],
outputs: [
{
to: treasuryAddress,
value: 10000n, // 10,000 satoshis payment
index: 1, // Output index (0 is reserved)
scriptPubKey: undefined,
flags: TransactionOutputFlags.hasTo,
},
],
});
// Simulation will now include the treasury output
const result = await contract.claim();
if (!result.revert) {
console.log('Claim would succeed with treasury payment');
}Simulating with Multiple Outputs
Complex operations may require multiple outputs, such as payments to different recipients or fee distributions.
import { TransactionOutputFlags } from 'opnet';
contract.setTransactionDetails({
inputs: [],
outputs: [
// Output 1: Treasury payment
{
to: treasuryAddress,
value: 5000n,
index: 1,
scriptPubKey: undefined,
flags: TransactionOutputFlags.hasTo,
},
// Output 2: Fee recipient
{
to: feeRecipient,
value: 1000n,
index: 2,
scriptPubKey: undefined,
flags: TransactionOutputFlags.hasTo,
},
],
});
const result = await contract.complexOperation();Simulating with Inputs
For contracts that verify specific input transactions, such as validating that funds come from a particular source, input details can be provided.
import { TransactionInputFlags } from 'opnet';
import { fromHex } from '@btc-vision/bitcoin';
contract.setTransactionDetails({
inputs: [
{
txId: fromHex('previous_tx_hash_hex'),
outputIndex: 0,
scriptSig: new Uint8Array(0),
witnesses: [],
flags: 0,
},
],
outputs: [
{
to: recipientAddress,
value: 50000n,
index: 1,
scriptPubKey: undefined,
flags: TransactionOutputFlags.hasTo,
},
],
});
const result = await contract.verifyAndProcess();Using Raw ScriptPubKey
For non-standard outputs that cannot be represented by a simple address, the raw scriptPubKey can be provided instead.
import { TransactionOutputFlags } from 'opnet';
import { fromHex } from '@btc-vision/bitcoin';
contract.setTransactionDetails({
inputs: [],
outputs: [
{
value: 10000n,
index: 1,
scriptPubKey: fromHex('76a914...88ac'), // P2PKH script
to: undefined,
flags: TransactionOutputFlags.hasScriptPubKey,
},
],
});Important Considerations
When using transaction details for simulation, keep the following points in mind:
- Output index 0 is reserved: The contract uses output index 0 for internal purposes. Always use index 1 or higher for custom outputs.
- Details are cleared after each call: The setTransactionDetails() method only applies to the next contract call. Subsequent calls require the details to be set again.
- Simulation must match execution: When sending the actual transaction, the extraOutputs parameter in TransactionParameters must match what was simulated to ensure consistent behavior.
Complete Workflow Example
The following example demonstrates the complete flow from simulation with transaction details to transaction execution.
import {
getContract,
IOP721Contract,
JSONRpcProvider,
OP_721_ABI,
TransactionOutputFlags,
TransactionParameters,
} from 'opnet';
import {
Address,
AddressTypes,
Mnemonic,
MLDSASecurityLevel,
} from '@btc-vision/transaction';
import { networks, PsbtOutputExtended } from '@btc-vision/bitcoin';
async function claimNFTWithPayment(): Promise<void> {
const network = networks.regtest;
const provider = new JSONRpcProvider('https://regtest.opnet.org', network);
const mnemonic = new Mnemonic(
'your seed phrase here ...',
'',
network,
MLDSASecurityLevel.LEVEL2
);
const wallet = mnemonic.deriveOPWallet(AddressTypes.P2TR, 0);
const nftContract = getContract<IOP721Contract>(
Address.fromString('0x...'),
OP_721_ABI,
provider,
network,
wallet.address
);
const treasuryAddress: string = 'bcrt1q...';
const paymentAmount: bigint = 10000n;
// Step 1: Set transaction details for simulation
nftContract.setTransactionDetails({
inputs: [],
outputs: [
{
to: treasuryAddress,
value: paymentAmount,
index: 1,
scriptPubKey: undefined,
flags: TransactionOutputFlags.hasTo,
},
],
});
// Step 2: Simulate the claim
const simulation = await nftContract.claim();
if (simulation.revert) {
console.error('Claim would fail:', simulation.revert);
return;
}
console.log('Simulation succeeded, gas:', simulation.estimatedSatGas);
// Step 3: Build transaction with matching outputs
const treasuryOutput: PsbtOutputExtended = {
address: treasuryAddress,
value: Number(paymentAmount),
};
const params: TransactionParameters = {
signer: wallet.keypair,
mldsaSigner: null,
refundTo: wallet.p2tr,
maximumAllowedSatToSpend: 50000n,
feeRate: 10,
network: network,
extraOutputs: [treasuryOutput], // Must match simulation
};
// Step 4: Send transaction
const receipt = await simulation.sendTransaction(params);
console.log('Transaction sent:', receipt.transactionId);
await provider.close();
}Examples
Check Balance Before Transfer
async function safeTransfer(
token: IOP20Contract,
recipient: Address,
amount: bigint
): Promise<boolean> {
// Check sender balance first
const balance = await token.balanceOf(token.from!);
if (balance.properties.balance < amount) {
console.error('Insufficient balance');
return false;
}
// Simulate transfer
const transfer = await token.transfer(recipient, amount, new Uint8Array(0));
if (transfer.revert) {
console.error('Transfer would fail:', transfer.revert);
return false;
}
console.log('Transfer would succeed');
console.log('Gas required:', transfer.estimatedGas);
return true;
}Check Allowance Before TransferFrom
async function safeTransferFrom(
token: IOP20Contract,
from: Address,
to: Address,
amount: bigint
): Promise<boolean> {
// Check allowance
const allowance = await token.allowance(from, token.from!);
if (allowance.properties.remaining < amount) {
console.error('Insufficient allowance');
return false;
}
// Check balance
const balance = await token.balanceOf(from);
if (balance.properties.balance < amount) {
console.error('Insufficient balance');
return false;
}
// Simulate
const transfer = await token.transferFrom(from, to, amount);
if (transfer.revert) {
console.error('TransferFrom would fail:', transfer.revert);
return false;
}
return true;
}Read Multiple Balances
async function getBalances(
token: IOP20Contract,
addresses: Address[]
): Promise<Map<string, bigint>> {
const results = await Promise.all(
addresses.map((addr) => token.balanceOf(addr))
);
const balances = new Map<string, bigint>();
addresses.forEach((addr, i) => {
const result = results[i];
if (!result.revert) {
balances.set(addr.toHex(), result.properties.balance);
}
});
return balances;
}