Smart Contract Deployment Guide

Deploy and interact with smart contracts on Shell Chain, including the full suite of post-quantum native precompiles.

See also: Quickstart Guide · JSON-RPC API Reference · Testnet Operator Guide · PQ Crypto Guide · Native Account Abstraction Guide


Overview

Shell Chain's PQVM provides EVM-familiar execution semantics. Solidity and Vyper contracts compile to standard EVM bytecode and run on Shell Chain without modification. Standard tooling — Hardhat, Foundry, Remix — all work via the standard RPC interface.

Key differences from standard EVM:

  • All classical Ethereum precompiles (0x01–0x09) are disabled, including ecrecover. Shell Chain replaces them with a post-quantum precompile suite at addresses 0x00010x0005.
  • CALLCODE (0xF2) and SELFDESTRUCT (0xFF) are disabled — they revert immediately if called.
  • Native addresses are 32-byte BLAKE3 hashes (0x + 64 lowercase hex). Standard 20-byte tooling interoperates through a compatibility layer.

Prerequisites

  • Node.js 18+ (for Hardhat)
  • Hardhat or Foundry installed
  • A running shell-chain node (see Quickstart)
  • A funded account (pre-allocated in genesis or received via transfer)

Connecting to Shell Chain

Network RPC URL Chain ID
Local http://localhost:8545 1337
Public Testnet https://testnet-rpc.shell.org 10

The local endpoint is the default JSON-RPC server started by shell-node run. The public testnet RPC is live at https://testnet-rpc.shell.org (see Testnet Operator Guide).


Hardhat Setup

Install Hardhat

mkdir my-shell-project && cd my-shell-project
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init

Configure networks

// hardhat.config.js
module.exports = {
  solidity: "0.8.26",
  networks: {
    shell: {
      url: "http://localhost:8545",
      chainId: 1337,
    },
    shellTestnet: {
      url: "https://testnet-rpc.shell.org",
      chainId: 10,
    }
  }
};
npx hardhat run scripts/deploy.js --network shell
npx hardhat run scripts/deploy.js --network shellTestnet

Foundry Setup

Install Foundry

curl --proto '=https' --tlsv1.2 -fsSL https://foundry.paradigm.xyz -o foundryup-init.sh
less foundryup-init.sh
bash foundryup-init.sh
rm foundryup-init.sh
foundryup

Deploy with Foundry

# Local node
forge create --rpc-url http://localhost:8545 --chain-id 1337 src/Counter.sol:Counter

# Public testnet
forge create --rpc-url https://testnet-rpc.shell.org --chain-id 10 src/Counter.sol:Counter

Example: Deploy a Counter Contract

1. Write the contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

contract Counter {
    uint256 public count;

    event CountChanged(uint256 newCount);

    function get() public view returns (uint256) {
        return count;
    }

    function increment() public {
        count += 1;
        emit CountChanged(count);
    }

    function decrement() public {
        require(count > 0, "Counter: cannot decrement below zero");
        count -= 1;
        emit CountChanged(count);
    }

    function reset() public {
        count = 0;
        emit CountChanged(count);
    }
}

2. Deploy with Hardhat

Create scripts/deploy.js:

const hre = require("hardhat");

async function main() {
  const Counter = await hre.ethers.getContractFactory("Counter");
  const counter = await Counter.deploy();
  await counter.waitForDeployment();
  console.log("Counter deployed to:", await counter.getAddress());
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
npx hardhat run scripts/deploy.js --network shell

3. Deploy with Foundry

forge create \
  --rpc-url http://localhost:8545 \
  --chain-id 1337 \
  src/Counter.sol:Counter

Interacting with a Deployed Contract

Read calls (no gas required)

Use eth_call to read state without submitting a transaction:

# Call the get() function (selector: 0x6d4ce63c)
curl -s http://localhost:8545 \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0",
    "method":"eth_call",
    "params":[{
      "to":"0x<CONTRACT_ADDRESS_64_HEX>",
      "data":"0x6d4ce63c"
    },"latest"],
    "id":1
  }'

Address format: Shell Chain uses 32-byte native addresses (0x + 64 lowercase hex) in RPC responses. Standard tooling works through the PQVM compatibility layer. Use shell-sdk when you need PQ-native signing or precise address handling.

With Hardhat (ethers.js):

const counter = await hre.ethers.getContractAt("Counter", "0xYOUR_CONTRACT_ADDRESS");
const count = await counter.get();
console.log("Current count:", count.toString());

Write calls (submits a transaction)

# Increment the counter (selector: 0xd09de08a)
curl -s http://localhost:8545 \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0",
    "method":"eth_sendRawTransaction",
    "params":["0x...signed_tx_bytes..."],
    "id":1
  }'

With Hardhat:

const counter = await hre.ethers.getContractAt("Counter", "0xYOUR_CONTRACT_ADDRESS");
const tx = await counter.increment();
await tx.wait();
console.log("Incremented! New count:", (await counter.get()).toString());

PQ Native Precompiles

Shell Chain replaces all classical Ethereum precompiles with a post-quantum suite. These are callable from Solidity using staticcall at the addresses below.

Precompile reference table

Address Name Gas Cost Purpose
0x0001 PQ_MLDSA65_VERIFY 46,000 (flat) Verify ML-DSA-65 or Dilithium3 signature
0x0002 PQ_SLHDSA_SHA2_256F_VERIFY 2,300,000 (flat) Verify SLH-DSA-SHA2-256f (SPHINCS+) signature
0x0003 PQ_MLDSA65_BATCH_VERIFY 12,000 × N (max 256 sigs) Batch-verify N ML-DSA-65 signatures atomically
0x0004 PQ_BLAKE3_256 30 + 6 × ⌈len/32⌉ BLAKE3-256 hash (32-byte output)
0x0005 PQ_BLAKE3_512 30 + 6 × ⌈len/32⌉ BLAKE3-512 hash / XOF (64-byte output)

All precompiles return 0x...00 (32-byte zero) on error or invalid input, rather than reverting.


0x0001 — ML-DSA-65 Verify

Verifies a single ML-DSA-65 (primary) or Dilithium3 (legacy) signature.

Input format:

[4 bytes: pubkey_len  (big-endian u32)] [pubkey bytes]
[4 bytes: msg_len     (big-endian u32)] [message bytes]
[remaining bytes]                       [signature bytes]

Output: 32 bytes — last byte 0x01 if valid, 0x00 if invalid.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

library PQVerify {
    address constant PQ_MLDSA_VERIFY = 0x0000000000000000000000000000000000000001;

    /// Verify an ML-DSA-65 or Dilithium3 signature. Returns true on valid.
    function verifyMLDSA(
        bytes memory pubkey,
        bytes memory message,
        bytes memory signature
    ) internal view returns (bool) {
        bytes memory input = abi.encodePacked(
            uint32(pubkey.length), pubkey,
            uint32(message.length), message,
            signature
        );
        (bool ok, bytes memory result) = PQ_MLDSA_VERIFY.staticcall(input);
        return ok && result.length >= 32 && result[31] == 0x01;
    }
}

0x0002 — SLH-DSA-SHA2-256f Verify

Verifies a single SLH-DSA-SHA2-256f (SPHINCS+) signature. This scheme is stateless hash-based and does not rely on lattice hardness assumptions.

Gas note: At 2,300,000 gas, SLH-DSA verification is expensive. Reserve it for high-value, infrequent operations such as governance votes or oracle attestations.

Input format (fixed-size fields, no length prefixes):

[64 bytes:    public key]
[49856 bytes: signature]
[remaining:   message bytes]

Output: 32 bytes — last byte 0x01 if valid, 0x00 if invalid.

address constant PQ_SLHDSA_VERIFY = 0x0000000000000000000000000000000000000002;

function verifySLHDSA(
    bytes memory pubkey,        // must be exactly 64 bytes
    bytes memory signature,     // must be exactly 49856 bytes
    bytes memory message
) internal view returns (bool) {
    require(pubkey.length == 64 && signature.length == 49856, "bad key/sig length");
    bytes memory input = abi.encodePacked(pubkey, signature, message);
    (bool ok, bytes memory result) = PQ_SLHDSA_VERIFY.staticcall(input);
    return ok && result.length >= 32 && result[31] == 0x01;
}

0x0003 — ML-DSA-65 Batch Verify

Batch-verifies up to 256 ML-DSA-65 signatures in a single call. Returns 0x01 only if all signatures are valid; 0x00 if any fails.

Gas is charged as 12,000 × count before verification begins. Batches over 256 signatures are rejected outright.

Input format:

[4 bytes: count (big-endian u32)]
[item_0][item_1]...[item_{count-1}]

Each item uses the same wire format as 0x0001:
  [4 bytes: pubkey_len][pubkey][4 bytes: msg_len][msg][signature]

Output: 32 bytes — last byte 0x01 if all valid, 0x00 otherwise.

address constant PQ_MLDSA_BATCH = 0x0000000000000000000000000000000000000003;

/// Batch-verify N ML-DSA-65 signatures. All must be valid for true to be returned.
function batchVerifyMLDSA(
    bytes[] memory pubkeys,
    bytes[] memory messages,
    bytes[] memory signatures
) internal view returns (bool) {
    require(
        pubkeys.length == messages.length &&
        messages.length == signatures.length &&
        pubkeys.length <= 256,
        "invalid batch"
    );
    uint32 count = uint32(pubkeys.length);
    bytes memory input = abi.encodePacked(count);
    for (uint256 i = 0; i < count; i++) {
        input = abi.encodePacked(
            input,
            uint32(pubkeys[i].length), pubkeys[i],
            uint32(messages[i].length), messages[i],
            signatures[i]
        );
    }
    (bool ok, bytes memory result) = PQ_MLDSA_BATCH.staticcall(input);
    return ok && result.length >= 32 && result[31] == 0x01;
}

0x0004 — BLAKE3-256 Hash

Computes the BLAKE3-256 hash of arbitrary input bytes. Returns a 32-byte hash.

Gas: 30 + 6 × ⌈len/32⌉

Input: Any byte sequence. Output: 32 bytes — the BLAKE3-256 hash.

address constant PQ_BLAKE3_256 = 0x0000000000000000000000000000000000000004;

/// Compute BLAKE3-256 hash. Returns bytes32(0) on out-of-gas.
function blake3_256(bytes memory data) internal view returns (bytes32) {
    (bool ok, bytes memory result) = PQ_BLAKE3_256.staticcall(data);
    require(ok && result.length == 32, "blake3-256 failed");
    return bytes32(result);
}

Example — hash a message and compare on-chain:

bytes32 expected = blake3_256(abi.encodePacked("hello shell"));
bytes32 actual   = blake3_256(abi.encodePacked(userInput));
require(expected == actual, "input mismatch");

0x0005 — BLAKE3-512 Hash

Computes a 64-byte BLAKE3 extended output. Useful for key derivation or when 256-bit output is insufficient.

Gas: 30 + 6 × ⌈len/32⌉ (same formula as 0x0004)

Output: 64 bytes.

address constant PQ_BLAKE3_512 = 0x0000000000000000000000000000000000000005;

function blake3_512(bytes memory data) internal view returns (bytes memory) {
    (bool ok, bytes memory result) = PQ_BLAKE3_512.staticcall(data);
    require(ok && result.length == 64, "blake3-512 failed");
    return result;
}


Using PQ Signatures for Deployment

Shell Chain uses ML-DSA-65 as its primary post-quantum signature scheme (with Dilithium3 legacy compatibility). To deploy contracts using PQ signatures, use the shell_sendTransaction RPC method:

# Sign the deployment transaction with the shell-node CLI
shell-node tx deploy \
  --code 0x608060405234801561001057600080fd5b50... \
  --keystore my-key.json \
  --rpc-url http://127.0.0.1:8545

# Or submit via JSON-RPC directly
curl -s http://localhost:8545 \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0",
    "method":"shell_sendTransaction",
    "params":[{
      "from": "0x<YOUR_ADDRESS_64_HEX>",
      "data": "0x608060405234801561001057600080fd5b50...",
      "gas": "0x100000",
      "maxFeePerGas": "0x5f7609",
      "maxPriorityFeePerGas": "0x0",
      "nonce": "0x0",
      "pqSignature": "0x...mldsa65_signature...",
      "pqPubkey": "0x...mldsa65_pubkey..."
    }],
    "id":1
  }'

Fee note: maxFeePerGas is only an example. Query eth_gasPrice and set maxFeePerGas ≥ the current base fee.

Note: Standard Ethereum wallets (MetaMask, etc.) use ECDSA signatures. For full PQ security, use the shell-node CLI or PQ-aware SDKs. See PQ Crypto Guide for details.


Verifying Contracts with debug_traceTransaction

After deploying a contract, use debug_traceTransaction to inspect the execution trace:

curl -s http://localhost:8545 \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc":"2.0",
    "method":"debug_traceTransaction",
    "params":["0xYOUR_TX_HASH"],
    "id":1
  }' | python3 -m json.tool

The trace shows the full call tree including:

  • CREATE / CREATE2 frames for contract deployment
  • Gas consumption per opcode
  • Storage reads and writes
  • Internal calls between contracts

Note: The debug namespace must be enabled with --rpc-api eth,net,web3,shell,debug.


PQVM Compatibility Notes

Shell Chain's PQVM retains Cancun-era opcode semantics for tooling compatibility, with the following differences.

Supported Cancun opcodes

Opcode EIP Description
TSTORE / TLOAD EIP-1153 Transient storage (cleared after each tx)
MCOPY EIP-5656 Efficient memory copy
BLOBHASH EIP-4844 Access blob versioned hashes
BLOBBASEFEE EIP-7516 Read blob base fee

Disabled opcodes

Opcode Hex Behavior
CALLCODE 0xF2 Reverts immediately — use DELEGATECALL instead
SELFDESTRUCT 0xFF Reverts immediately — contract destruction is not supported

Disabled precompiles

All classical Ethereum precompiles (0x010x09) are disabled. Calling them returns empty bytes without reverting. In particular:

  • ecrecover (0x01) — returns empty. Do not use ecrecover or any library that wraps it. Use PQ_MLDSA65_VERIFY (0x0001) instead.
  • sha256 (0x02), ripemd160 (0x03) — return empty. Use PQ_BLAKE3_256 (0x0004) for on-chain hashing.
  • identity (0x04), bn128 ops (0x060x08), blake2f (0x09) — return empty.

Transaction formats supported

PQTx is the canonical Shell transaction format. Legacy Ethereum transaction envelopes are still accepted for tooling compatibility.

Type EIP Description
Legacy (type 0) Traditional transactions
Access list (type 1) EIP-2930 Transactions with access lists
EIP-1559 (type 2) EIP-1559 Dynamic fee transactions
Blob (type 3) EIP-4844 Blob-carrying transactions

Gas model

Shell Chain uses a PQTx-native fee model compatible with EIP-1559 tooling:

  • baseFeePerGas adjusts per-block based on gas utilization
  • maxPriorityFeePerGas is always 0x0 on this PoA chain
  • Use eth_gasPrice to get the current base fee
  • Use eth_feeHistory for historical fee data

Gas Estimation Tips

  1. Use eth_estimateGas before submitting transactions. The estimate includes a 20% buffer (gas_used × 1.2) with a minimum of 21,000.

  2. Check the base fee with eth_gasPrice. Set maxFeePerGas ≥ the base fee or the transaction will be rejected.

  3. Access lists save gas for contracts that touch many storage slots. Use eth_createAccessList to generate one:

    curl -s http://localhost:8545 \
      -H "Content-Type: application/json" \
      -d '{
         "jsonrpc":"2.0",
         "method":"eth_createAccessList",
         "params":[{"to":"0x<CONTRACT>","data":"0x..."},"latest"],
         "id":1
      }'
    
  4. PQ precompile gas costs are fixed regardless of key or message size (except BLAKE3, which scales linearly). Estimate them separately from your contract logic:

    • ML-DSA-65 single verify: 46,000
    • SLH-DSA verify: 2,300,000 (budget carefully — near 2× a standard EVM transaction limit)
    • ML-DSA-65 batch of N: 12,000 × N
    • BLAKE3 (256 or 512) of N bytes: 30 + 6 × ⌈N/32⌉
  5. Transient storage (TSTORE/TLOAD) is cheaper than regular storage for data only needed within a single transaction.

  6. Gas limit is set in genesis (default: 30,000,000). Check with eth_getBlockByNumber.


Further Reading


Last updated: 2026-06-01