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, but deployment and state-changing calls must be signed with Shell Chain's post-quantum account format. Use shell-sdk/contracts for deployment, writes, reads, and receipt polling.

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 0x00010x0006.
  • CALLCODE (0xF2) and SELFDESTRUCT (0xFF) are disabled — they revert immediately if called.
  • Native addresses are 32-byte BLAKE3 hashes (0x + 64 lowercase hex).
  • Smart contracts should use Solidity's address keyword for account, owner, and recipient fields. Shell Chain's compiler/runtime and shell-sdk/contracts encode those ABI address values as Shell-native addresses for this chain.

Prerequisites

  • Node.js 20+
  • shell-sdk 0.13.0+ for compilation, PQ-native signing, Shell 32-byte address handling, and contract calls
  • 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).


Use shell-sdk/contracts so deployment and writes go through Shell-native post-quantum signing and shell_sendTransaction. Ethereum deployment tools that assume ECDSA signers cannot deploy Shell Chain contracts directly.

Runtime helpers:

  • deployContract builds, signs, broadcasts, waits, and validates the 32-byte Shell contract address.
  • writeContract builds, signs, broadcasts, and waits for state-changing calls.
  • readContract performs eth_call and decodes the ABI result.
  • waitForTransactionReceipt provides bounded receipt polling.

Node-only compiler helpers:

  • compileSolidity compiles Solidity with solc and returns a normalized Shell artifact.
  • loadContractArtifact and saveContractArtifact handle artifact IO.
import { readFile } from "node:fs/promises";
import { createShellProvider, decryptKeystore } from "shell-sdk";
import { deployContract, readContract, writeContract } from "shell-sdk/contracts";
import { compileSolidity } from "shell-sdk/contracts/compiler";

const provider = createShellProvider({ rpcHttpUrl: "http://127.0.0.1:8545" });
const keystore = JSON.parse(await readFile("my-key.json", "utf8"));
const signer = await decryptKeystore(keystore, process.env.SHELL_KEYSTORE_PASSWORD!);

const artifact = await compileSolidity({
  sources: [{ path: "contracts/Counter.sol" }],
  contractName: "Counter",
  outputPath: "artifacts/Counter.json",
});

const deployed = await deployContract({
  provider,
  signer,
  chainId: 1337,
  artifact,
  gasLimit: 1_500_000,
  wait: true,
});

await writeContract({
  provider,
  signer,
  chainId: 1337,
  address: deployed.contractAddress!,
  abi: artifact.abi,
  functionName: "increment",
  gasLimit: 120_000,
  wait: true,
});

const count = await readContract({
  provider,
  address: deployed.contractAddress!,
  abi: artifact.abi,
  functionName: "get",
});

Use shell-sdk/contracts/compiler only from Node scripts. Browser DApps should use precompiled artifacts and import only shell-sdk/contracts.

The SDK also ships a CLI backed by the same helpers:

npx shell-sdk contract compile --source contracts/Counter.sol --contract Counter --out artifacts/Counter.json
npx shell-sdk contract deploy --artifact artifacts/Counter.json --keystore my-key.json --password "$SHELL_KEYSTORE_PASSWORD"
npx shell-sdk contract write --artifact artifacts/Counter.json --address 0x... --function increment
npx shell-sdk contract read --artifact artifacts/Counter.json --address 0x... --function get

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. Compile and deploy with Shell SDK

Create scripts/deploy-counter.ts:

import { readFile } from "node:fs/promises";
import { createShellProvider, decryptKeystore } from "shell-sdk";
import { deployContract, readContract, writeContract } from "shell-sdk/contracts";
import { compileSolidity } from "shell-sdk/contracts/compiler";

const rpcHttpUrl = process.env.SHELL_RPC_URL ?? "http://127.0.0.1:8545";
const chainId = Number(process.env.SHELL_CHAIN_ID ?? 1337);
const keystorePath = process.env.SHELL_KEYSTORE_PATH ?? "my-key.json";
const password = process.env.SHELL_KEYSTORE_PASSWORD;

if (!password) {
  throw new Error("Set SHELL_KEYSTORE_PASSWORD before deploying");
}

const provider = createShellProvider({ rpcHttpUrl });
const keystore = JSON.parse(await readFile(keystorePath, "utf8"));
const signer = await decryptKeystore(keystore, password);

const artifact = await compileSolidity({
  sources: [{ path: "contracts/Counter.sol" }],
  contractName: "Counter",
  outputPath: "artifacts/Counter.json",
});

const deployed = await deployContract({
  provider,
  signer,
  chainId,
  artifact,
  gasLimit: 1_500_000,
  wait: true,
});

console.log("contract:", deployed.contractAddress);

await writeContract({
  provider,
  signer,
  chainId,
  address: deployed.contractAddress!,
  abi: artifact.abi,
  functionName: "increment",
  gasLimit: 120_000,
  wait: true,
});

const count = await readContract({
  provider,
  address: deployed.contractAddress!,
  abi: artifact.abi,
  functionName: "get",
});

console.log("count:", count);

Run it against a local node:

SHELL_RPC_URL=http://127.0.0.1:8545 \
SHELL_CHAIN_ID=1337 \
SHELL_KEYSTORE_PATH=my-key.json \
SHELL_KEYSTORE_PASSWORD=dev-password \
npx tsx scripts/deploy-counter.ts

For public testnet, set SHELL_RPC_URL=https://testnet-rpc.shell.org, SHELL_CHAIN_ID=10, and use a funded Shell keystore.


Example: Build an NFT DApp

For a full reproducible project, use the NFT DApp Tutorial. It creates shell-nft-dapp, a Vite + React project that:

  • compiles a ShellNft Solidity contract with shell-sdk/contracts/compiler,
  • deploys through deployContract,
  • mints an NFT to a Shell address owner through writeContract,
  • reads totalSupply, ownerOf, and tokenURI through readContract,
  • includes CLI scripts and a browser UI for the same flow.

The tutorial intentionally uses Solidity address owners. Do not replace address with bytes32 in contract source to work around address width; the Shell compiler/runtime and SDK contract helpers are responsible for making ABI address values conform to Shell Chain.


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 Shell SDK:

const count = await readContract({
  provider,
  address: "0xYOUR_CONTRACT_ADDRESS",
  abi: artifact.abi,
  functionName: "get",
});

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 Shell SDK:

await writeContract({
  provider,
  signer,
  chainId,
  address: "0xYOUR_CONTRACT_ADDRESS",
  abi: artifact.abi,
  functionName: "increment",
  gasLimit: 120_000,
  wait: true,
});

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)
0x0006 PQ_ADDRESS_DERIVE 200 (flat) Derive a 32-byte Shell address from `algo_id

Verify precompiles return 0x...01 on success and 0x...00 on invalid signatures. Hash precompiles return raw digest bytes. PQ_ADDRESS_DERIVE returns a 32-byte address on valid input and reports precompile failure on empty input or unknown algo_id.


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 1 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. Empty batches and 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 > 0 &&
        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;
}

0x0006 — PQ Address Derive

Derives the canonical 32-byte Shell address for a public key using the same rule as native accounts: BLAKE3(algo_id || pubkey).

Gas: 200 flat

Input format:

[1 byte: algo_id] [remaining bytes: public key]
algo_id Algorithm
0x00 Dilithium3 legacy compatibility
0x01 ML-DSA-65
0x02 SLH-DSA-SHA2-256f

Output: 32 bytes — the canonical Shell address.

address constant PQ_ADDRESS_DERIVE = 0x0000000000000000000000000000000000000006;

function deriveShellAddress(uint8 algoId, bytes memory pubkey) internal view returns (bytes32) {
    bytes memory input = abi.encodePacked(bytes1(algoId), pubkey);
    (bool ok, bytes memory result) = PQ_ADDRESS_DERIVE.staticcall(input);
    require(ok && result.length == 32, "pqaddr failed");
    return bytes32(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 shell-sdk/contracts; it signs with the funded Shell keystore and submits the correct shell_sendTransaction payload for you:

import { readFile } from "node:fs/promises";
import { createShellProvider, decryptKeystore } from "shell-sdk";
import { deployContract } from "shell-sdk/contracts";
import { loadContractArtifact } from "shell-sdk/contracts/compiler";

const provider = createShellProvider({ rpcHttpUrl: "http://127.0.0.1:8545" });
const keystore = JSON.parse(await readFile("my-key.json", "utf8"));
const signer = await decryptKeystore(keystore, process.env.SHELL_KEYSTORE_PASSWORD!);
const artifact = await loadContractArtifact("artifacts/Counter.json");

const deployed = await deployContract({
  provider,
  signer,
  chainId: 1337,
  artifact,
  gasLimit: 1_500_000,
  wait: true,
});

console.log(deployed.hash, deployed.contractAddress);

Fee note: the SDK applies safe defaults. For congested networks, query eth_gasPrice and pass explicit maxFeePerGas / maxPriorityFeePerGas.

Note: Standard Ethereum wallets use ECDSA signatures and cannot sign Shell Chain transactions. Use the Shell SDK or another PQ-aware signer. 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% integer buffer 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 deterministic. Verification has a fixed per-operation or per-signature cost, while hashing and address derivation scale with input length:

    • 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⌉
    • PQ address derive: 200 + 6 × ⌈pubkey_len/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-07-28