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 addresses0x0001–0x0005. CALLCODE(0xF2) andSELFDESTRUCT(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. Useshell-sdkwhen 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:
maxFeePerGasis only an example. Queryeth_gasPriceand setmaxFeePerGas≥ the current base fee.Note: Standard Ethereum wallets (MetaMask, etc.) use ECDSA signatures. For full PQ security, use the
shell-nodeCLI 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/CREATE2frames for contract deployment- Gas consumption per opcode
- Storage reads and writes
- Internal calls between contracts
Note: The
debugnamespace 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 (0x01–0x09) are disabled. Calling them returns empty bytes without reverting. In particular:
ecrecover(0x01) — returns empty. Do not useecrecoveror any library that wraps it. UsePQ_MLDSA65_VERIFY(0x0001) instead.sha256(0x02),ripemd160(0x03) — return empty. UsePQ_BLAKE3_256(0x0004) for on-chain hashing.identity(0x04), bn128 ops (0x06–0x08),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:
baseFeePerGasadjusts per-block based on gas utilizationmaxPriorityFeePerGasis always0x0on this PoA chain- Use
eth_gasPriceto get the current base fee - Use
eth_feeHistoryfor historical fee data
Gas Estimation Tips
-
Use
eth_estimateGasbefore submitting transactions. The estimate includes a 20% buffer (gas_used × 1.2) with a minimum of 21,000. -
Check the base fee with
eth_gasPrice. SetmaxFeePerGas≥ the base fee or the transaction will be rejected. -
Access lists save gas for contracts that touch many storage slots. Use
eth_createAccessListto 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 }' -
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⌉
-
Transient storage (
TSTORE/TLOAD) is cheaper than regular storage for data only needed within a single transaction. -
Gas limit is set in genesis (default: 30,000,000). Check with
eth_getBlockByNumber.
Further Reading
- JSON-RPC API Reference — Full list of all 61 RPC methods
- PQ Crypto Guide — Post-quantum signature schemes and key formats
- Testnet Operator Guide — Running validator nodes
- Quickstart Guide — Get a node running in 5 minutes
- Account Abstraction Guide — Native AA for contract wallets
Last updated: 2026-06-01