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. Standard Solidity address is 20 bytes; encoding a full Shell identity in the SDK cannot widen that compiled type. 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

  • Node.js 20+
  • A local Shell Chain node
  • A funded Shell keystore from the quickstart
  • shell-nft-dapp checked out locally

Clone the example app:

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

The example lockfile installs published [email protected]; standalone installation and compilation do not require a sibling SDK repository.

Transaction compatibility: SDK 0.13.0 signs V1 transactions, while current node source expects V2. A compatible public SDK release is still required for the standalone deploy/mint flow against that node. A successful compile or browser build does not establish transaction compatibility.

For source development, keep a compatible built SDK in a sibling checkout. After npm ci, override only the installed dependency with npm install --no-save --package-lock=false ../shell-sdk. Record both source commits when running the smoke flow; this override does not validate the published-package path.

Example sibling layout:

local-checkouts/
  shell-sdk/
  shell-nft-dapp/

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 \
  --rpc-api eth,net,web3,shell

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