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 addresses0x0001–0x0006. CALLCODE(0xF2) andSELFDESTRUCT(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
addresskeyword for account, owner, and recipient fields. Shell Chain's compiler/runtime andshell-sdk/contractsencode those ABIaddressvalues 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).
Recommended Shell SDK Contract Flow
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:
deployContractbuilds, signs, broadcasts, waits, and validates the 32-byte Shell contract address.writeContractbuilds, signs, broadcasts, and waits for state-changing calls.readContractperformseth_calland decodes the ABI result.waitForTransactionReceiptprovides bounded receipt polling.
Node-only compiler helpers:
compileSoliditycompiles Solidity withsolcand returns a normalized Shell artifact.loadContractArtifactandsaveContractArtifacthandle 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
ShellNftSolidity contract withshell-sdk/contracts/compiler, - deploys through
deployContract, - mints an NFT to a Shell
addressowner throughwriteContract, - reads
totalSupply,ownerOf, andtokenURIthroughreadContract, - 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. Useshell-sdkwhen 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_gasPriceand pass explicitmaxFeePerGas/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/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% integer buffer 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 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⌉
-
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-07-28