Build a Shell NFT DApp

Create, deploy, mint, and read a Shell-native NFT contract with a reproducible local workflow.

See also: Quickstart Guide · Smart Contract Guide · JSON-RPC API Reference


What you will build

This tutorial uses shell-nft-dapp, a small Vite + React + TypeScript project with Node scripts for the same deploy/mint/read flow. The project uses shell-sdk/contracts for deployment, writes, reads, and receipt polling, and uses shell-sdk/contracts/compiler for Node-only Solidity compilation.

The contract is intentionally Shell-native NFT, not a marketplace-focused ERC-721 compatibility claim. Owners use Solidity bytes32 so all 32 bytes of a Shell address survive calldata decoding, storage, reads and events. This example retains its existing bytes32 ABI. New contracts using Solidity address can select the SDK compiler's target: "pqvm" option for Shell-native address semantics. The example targets Cancun instructions supported by the node.

Redeploy the example after this owner ABI change. Previously deployed contracts using address owners are incompatible with the new mint and event signatures.

The contract exposes:

  • mint(bytes32 to, string uri)
  • ownerOf(uint256 tokenId) returns (bytes32)
  • tokenURI(uint256 tokenId) returns (string)
  • totalSupply() returns (uint256)
  • TransferShell(bytes32 from, bytes32 to, uint256 tokenId)

Prerequisites

Clone the example app:

git clone https://github.com/ShellDAO/shell-nft-dapp.git
cd shell-nft-dapp

The example lockfile pins the published [email protected] prerelease, which signs V2 transactions for Shell Chain 0.27.10. npm ci installs this exact SDK without a sibling checkout or a local dependency override. SDK 0.13.0 signs V1 and fails signature verification against this node.

The CLI deploy/mint/read flow was checked with these public packages on Node.js 24 and macOS arm64, using a local full RocksDB node and an ML-DSA-65 keystore. Full owner identity, mint events, rejected mints and reads after restarting the node were checked. Browser verification covers type checking and the production build. This does not establish compatibility with the public testnet's node version or activate a protocol upgrade there.


1. Start a local Shell node

Follow the Quickstart Guide through key generation and genesis initialization. Start the node with CORS enabled for the browser DApp:

shell-node run \
  --datadir shell-data \
  --keystore my-key.json \
  --password-file .quickstart-password \
  --rpc-addr 127.0.0.1:8545 \
  --rpc-cors "http://127.0.0.1:5173,http://localhost:5173" \
  --block-time 2000 \
  --max-idle-interval 0 \
  --network dev \
  --db rocksdb \
  --rpc-api eth,net,web3,shell

The --db rocksdb setting keeps deployments and tokens in shell-data across node restarts. Dev mode otherwise selects an ephemeral in-memory database.

Keep this terminal running.


2. Install the DApp

Open a second terminal:

cd shell-nft-dapp
npm ci
cp .env.example .env

Edit .env:

SHELL_RPC_URL=http://127.0.0.1:8545
SHELL_CHAIN_ID=1337
SHELL_KEYSTORE_PATH=../shell-chain/my-key.json
SHELL_KEYSTORE_PASSWORD=dev-password
NFT_BASE_URI=ipfs://example/
SHELL_NFT_CONTRACT=

Use the actual keystore path and password from the quickstart.

Leave SHELL_NFT_CONTRACT empty for the first deploy. After npm run deploy, the scripts save the deployed contract address in deployments/local.json; mint and read use that file automatically. Set SHELL_NFT_CONTRACT only when you want to target an existing deployed contract directly.


3. Compile the contract

npm run compile

Expected output:

compiled .../artifacts/ShellNft.compiled.json
contract=ShellNft bytecode=... bytes

The generated artifact is local build output and is not committed.


4. Run the complete smoke flow

npm run smoke

The smoke script:

  1. Compiles contracts/ShellNft.sol through shell-sdk/contracts/compiler.
  2. Detects chain ID, balance, and pending nonce.
  3. Deploys ShellNft through deployContract.
  4. Mints token 1 to the signer address through writeContract.
  5. Reads totalSupply(), ownerOf(1), and tokenURI(1) through readContract.
  6. Checks the mint event's full owner identity, rejects empty-URI and zero-owner mints, and verifies that supply, owner and URI remain unchanged.

Expected output ends with:

smoke ok
contract: 0x...
mint tx: 0x...
owner: 0x...
tokenURI: ipfs://example/shell-nft-1.json

owner is a 32-byte Shell address.


5. Run each step manually

Deploy:

npm run deploy

Mint:

npm run mint -- ipfs://example/shell-nft-1.json

Read token 1:

npm run read -- 1

mint and read validate the configured or saved contract address before sending RPC calls, so malformed 20-byte or truncated addresses fail before a transaction is submitted.


6. Use the browser DApp

Start Vite:

npm run dev

Open http://127.0.0.1:5173.

In the page:

  1. Keep RPC URL as http://127.0.0.1:8545.
  2. Keep chain ID as 1337.
  3. Paste the keystore JSON from my-key.json.
  4. Enter the keystore password.
  5. Paste bytecode from artifacts/ShellNft.compiled.json.
  6. Click Deploy.
  7. Click Mint to Signer.
  8. Click Read.

The page displays totalSupply, the owner address, and the token URI.

The browser path uses shell-sdk/contracts runtime helpers. It does not import the compiler subpath; compilation stays in the Node CLI because solc is not a browser dependency.


Optional: testnet RPC

Set:

SHELL_RPC_URL=https://testnet-rpc.shell.org
SHELL_CHAIN_ID=10
SHELL_KEYSTORE_PATH=/path/to/funded-testnet-keystore.json
SHELL_KEYSTORE_PASSWORD=...

Testnet execution depends on external RPC availability and a funded account. Keep the local node path as the required reproducible tutorial path.


Troubleshooting

Symptom Fix
Browser fetch blocked by CORS Restart shell-node with --rpc-cors "http://127.0.0.1:5173,http://localhost:5173"
SHELL_CHAIN_ID mismatch Query eth_chainId or set the chain ID for the selected network
deployer has no balance Use the funded quickstart keystore or fund the account before deploying
SHELL_NFT_CONTRACT is required Run npm run deploy first, or set SHELL_NFT_CONTRACT to an existing 32-byte Shell contract address
0x + 64 hex chars error Use a Shell canonical 32-byte address, not a 20-byte Ethereum address
Transaction timeout Confirm the node is producing blocks and RPC eth_getTransactionReceipt is reachable