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+
- A matching shell-sdk source build for compilation, PQ-native signing, Shell 32-byte address handling, and contract calls
- solc 0.8.30+ for Node-side Solidity compilation, plus tsx to run the TypeScript examples
- A running local shell-chain node built from the verified revision below (see Quickstart)
- A funded account (pre-allocated in genesis or received via transfer)
Match the node and SDK
The npm release [email protected] uses the V1 transaction signing format.
The current node source requires V2, which also commits the transaction's
access list. Mixing these versions fails with PQ signature verification failed.
The SDK source already implements V2; use the verified source pair for this
local tutorial until a compatible npm release is available:
| Component | Verified source revision |
|---|---|
| shell-chain | e5e759c |
| shell-sdk | 86c5b68 |
Build the node from that revision using the Quickstart, then create a separate SDK checkout and local example project:
git clone --no-checkout https://github.com/ShellDAO/shell-sdk.git
cd shell-sdk
git checkout 86c5b689ecaeb41c3cdc883af865cac4e30bab07
npm ci
npm run build
npm pack
cd ..
mkdir shell-counter
cd shell-counter
npm init -y
npm pkg set type=module
npm install ../shell-sdk/shell-sdk-0.13.0.tgz
npm install --save-dev solc@^0.8.30 tsx
mkdir -p contracts scripts
The source archive still has 0.13.0 in its filename; it contains the pinned
source revision above and is different from the npm registry release.
The SDK's compiler dependency is optional so browser applications can use the
runtime helpers without bundling Solidity tooling. Install solc explicitly
for the compiler and CLI examples below. Use the funded my-key.json from the
Quickstart, or set SHELL_KEYSTORE_PATH to its location; keep the keystore and
password out of source control.
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. After saving the Counter
source from the example below, set SHELL_KEYSTORE_PASSWORD and run these
commands. Replace 0x... with the contractAddress returned by deployment;
both deployment and writes require the funded keystore:
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 --keystore my-key.json --password "$SHELL_KEYSTORE_PASSWORD"
npx shell-sdk contract read --artifact artifacts/Counter.json --address 0x... --function get
Example: Deploy a Counter Contract
1. Write the contract
Save this as contracts/Counter.sol:
// 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
The script should print a 32-byte contract address and count: 1n: deployment
and the increment transaction have both confirmed, and the read returns the
updated state.
For public testnet, first confirm that the deployed node and SDK use the same
signing format; the source pair above is verified against a local node only.
Then 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