"Smart Contract Best Practices

Recommendations

The following recommendations address common patterns and pitfalls specific to smart contract interactions on OP_NET. Applying these practices consistently helps prevent failed transactions and wasted fees.

Store Contract References

Create contract instances once and reuse them throughout your application rather than calling getContract() repeatedly for the same address.

Store Contract Referencestypescript
// Good: Create once, reuse
const token = getContract<IOP20Contract>(addr, abi, provider, network);
await token.balanceOf(addr1);
await token.balanceOf(addr2);

// Bad: Creating new instances
await getContract(addr, abi, provider, network).balanceOf(addr1);
await getContract(addr, abi, provider, network).balanceOf(addr2);

Simulate Before Sending and Always Check Reverts

Every state-changing operation must be simulated before broadcasting. Simulation validates the contract call against the current blockchain state without spending any satoshis on fees. Always inspect the simulation.revert field before proceeding. A non-null value indicates the operation would fail on-chain, and broadcasting it would result in lost fees with no state change. Treat simulation as a mandatory gate, never as an optional optimization.

Simulate and Check Revertstypescript
// Always simulate first
const simulation = await contract.transfer(to, amount, new Uint8Array(0));

// Only send if simulation succeeds
if (!simulation.revert) {
    const tx = await simulation.sendTransaction(params);
}

Use Batch Reads

When querying multiple values from the same contract batch the calls into a single operation wherever the API supports it. Methods like provider.getBalances() accept arrays and return results in one round-trip, significantly reducing latency and RPC load compared to issuing individual requests in a loop.

For multiple distinct method calls on the same contract instance, use Promise.all() to execute them concurrently instead of awaiting each one sequentially.

Use Batch Readstypescript
// Good: Parallel
const [a, b, c] = await Promise.all([
    contract.methodA(),
    contract.methodB(),
    contract.methodC(),
]);

// Slower: Sequential
const a = await contract.methodA();
const b = await contract.methodB();
const c = await contract.methodC();

Track UTXOs Between Transactions

After each broadcast, the consumed UTXOs are no longer spendable. If you send a follow-up transaction before the previous one confirms, you must pass the newUTXOs from the prior receipt as inputs to avoid double-spend rejections. Use the UTXOsManager to track UTXO state across sequential operations, and always update your local UTXO set after every successful broadcast.

Track UTXOstypescript
let currentUtxos: UTXO[] | undefined;

async function sendMultiple() {
    for (const tx of transactions) {
        const params = { ...baseParams, utxos: currentUtxos };
        const receipt = await simulation.sendTransaction(params);
        currentUtxos = receipt.newUTXOs;
    }
}

Set Reasonable Spending Limits

Always set maximumAllowedSatToSpend to a realistic upper bound for the operation at hand. This parameter acts as a safety cap. Avoid setting this value excessively high to protect against unexpected fee spikes.

Set Reasonable Spending Limitstypescript
// Don't allow unlimited spending
const params: TransactionParameters = {
    maximumAllowedSatToSpend: 50000n,  // Max 50k sats
    // ...
};

Handle Network Congestion

During periods of high mempool activity, the default or low-tier fee rate may not be sufficient for timely confirmation. Query the provider's gasParameters() endpoint to obtain real-time recommended fee rates at low, medium, and high tiers, and select the appropriate tier based on your confirmation urgency.

Handle Network Congestiontypescript
// Increase fee rate during congestion
const gasParams = await provider.gasParameters();
const highFee = gasParams.bitcoin.recommended.high;

const params: TransactionParameters = {
    feeRate: highFee,
    // ...
};

Offline Signing

Offline signing keeps private keys on a device that never connects to the internet, significantly reducing the attack surface. However, this workflow introduces additional complexity around data transfer, timing, and UTXO validity. Follow these guidelines to ensure a smooth and secure process:

  • Use a physically isolated device for signing.
  • Double-check recipient and amount before signing.
  • Use toOfflineBuffer() for efficient, compact data transfer.
  • Challenges expire, so don't delay too long between preparation and signing (typically ~10 seconds cache).
  • Test the workflow on regtest before mainnet.
  • The offline data includes UTXOs; ensure they haven't been spent between preparation and broadcast.

Refund Address

The refund address receives all change from your transactions. An incorrect or inaccessible refund address results in permanent loss of funds. Keep these practices in mind when configuring the refund address:

  • Always use an address you control.
  • Prefer P2TR (Taproot) addresses.
  • Never use exchange deposit addresses.
  • Keep the refund address consistent within batch operations.