bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 54쪽 중 23쪽
-ignition @nomicfoundation/hardhat-ignition-ethers
```
\
Create `.env`:
```
PRIVATE_KEY=0xYourPrivateKey
SOMNIA_RPC_HTTPS=https://dream-rpc.somnia.network
```
<details>
<summary><code>hardhat.config.ts</code></summary>
```typescript
import "dotenv/config";
import "@nomicfoundation/hardhat-verify";
import "@nomicfoundation/hardhat-ignition-ethers";
import { HardhatUserConfig } from "hardhat/config";
const config: HardhatUserConfig = {
solidity: "0.8.28",
networks: {
somnia: {
url: process.env.SOMNIA_RPC_HTTPS!,
accounts: [process.env.PRIVATE_KEY!],
},
},
sourcify: { enabled: false },
etherscan: {
apiKey: { somnia: process.env.SOMNIA_EXPLORER_API_KEY || "" },
customChains: [
{
network: "somnia",
chainId: 50312,
urls: {
apiURL: "https://shannon-explorer.somnia.network/api",
browserURL: "https://shannon-explorer.somnia.network",
},
},
],
},
};
export default config;
```
</details>
Add the `Smart Contract` to the Contract directory
## Deploy with Ignition
Create `ignition/modules/NFTTest.ts`:
```ts
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
const NFTTestModule = buildModule("NFTTestModule", (m) => {
const initialOwner = m.getParameter("initialOwner", "0xYourOwnerAddress");
const nft = m.contract("NFTTest", [initialOwner]);
return { nft };
});
export default NFTTestModule;
```
Deploy:
```bash
npx hardhat ignition deploy ignition/modules/NFTTest.ts --network somnia
```
Copy the **deployed address** from the output.
***
## Verify Smart Contract
```bash
npx hardhat verify --network somnia <DEPLOYED_ADDRESS> 0xYourOwnerAddress
```
> Ensure compiler version, optimizer, and constructor args match.
***
### Mint your collection
We’ll mint by calling `safeMint(to, uri)` **10 times**, matching your uploaded metadata files.
<details>
<summary><code>scripts/mint.ts</code></summary>
```ts
import { ethers } from "hardhat";
async function main() {
const contractAddr = "<DEPLOYED_ADDRESS>";
const nft = await ethers.getContractAt("NFTTest", contractAddr);
const owner = (await ethers.getSigners())[0];
// Example: 10 tokens at /ipfs/<CID>/{0..9}.json
const CID = "<YOUR_METADATA_CID>";
for (let i = 0; i < 10; i++) {
const uri = `/ipfs/${CID}/${i}.json`;
const tx = await nft.safeMint(owner.address, uri);
console.log(`Mint tx ${i}:`, tx.hash);
await tx.wait();
}
const lastId = await nft.callStatic.safeMint(owner.address, `/ipfs/${CID}/999.json`).catch(()=>null);
console.log("Minted 10 tokens. Next simulated ID (no state change):", lastId ?? "N/A");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
```
</details>
Run the project:
```bash
npx hardhat run scripts/mint.ts --network somnia
```
***
## Inspect Token metadata
Read a token’s URI (e.g., tokenId `0`) and open it in your browser.
`scripts/read-uri.ts`:
```ts
import { ethers } from "hardhat";
async function main() {
const contractAddr = "<DEPLOYED_ADDRESS>";
const nft = await ethers.getContractAt("NFTTest", contractAddr);
const uri = await nft.tokenURI(0);
console.log("tokenURI(0):", uri);
}
main().catch(console.error);
```
```bash
npx hardhat run scripts/read-uri.ts --network somnia
```
Open the printed URL (it should be `https://ipfs.io/ipfs/<CID>/0.json`) in your browser to confirm JSON and `image` render correctly.
Congratulations. 🎉 You have deployed your first ERC721 Smart Contract to the Somnia Network. 🎉
# Managing NFT Metadata with IPFS
In this guide, you will rely on an IPFS workflow for ERC721 collections on Somnia.
This guide will walk you through how\
\- You will prepare and upload artwork to IPFS via Pinata.\
\- Generate clean, wallet/marketplace-friendly metadata JSON\
\- Upload the metadata to IPFS via Pinata Cloud\
\- Deploy a Solidity contract that stores the metadata URIs onchain (for each token)\
\- Mint your NFTs.
All metadata resides on IPFS, while only the URIs (pointers) are stored onchain.
Please note that this guide provides a Smart Contract option for fully onchain metadata.
## Concepts You Should Know
* [IPFS](https://ipfs.tech/) (InterPlanetary File System) is a “Content Addressed Storage Network”. Files are addressed by their CID (content identifier). If any bit changes, the CID changes, and it is great for integrity and verifiability.
* [Pinata](https://pinata.cloud/): Pinata pins your files to IPFS and keeps them available. You’ll get CIDs (content identifiers) that never change for the same content.
* TokenURI: an ERC721 method that returns a URL (often an ipfs\:// URI) pointing to a JSON file describing the NFT token (name, description, image, attributes, etc.).
* Per token URI vs `baseURI`:
* Per token URI (this guide): store the full JSON URI onchain for each token with ERC721URIStorage. Easiest and most flexible.
* `baseURI` pattern: compute tokenURI = baseURI + tokenId (no per token storage). Cheaper for large drops, but requires sequential naming.
## Prerequisites
* MetaMask is configured for a Somnia RPC and has sufficient funds for gas
* Node.js 20+ (only if you’ll run the helper scripts below)
* Pinata account and a JWT (recommended): Get your JWT on Pinata
* `Pinata Dashboard → API Keys → New Key → choose Admin/Scoped as needed → copy JWT`
## Prepare Images
If your art varies wildly in size or format, normalize once for a consistent user experience. Create an `assets` directory for your images. Run the node script below, targeting the `assets` directory to give uniformity to your images.
Install the dependencies:
```bash
npm i sharp fast-glob
```
Run the script:
```typescript
// scripts/resize.js
import fg from "fast-glob";
import sharp from "sharp";
import { mkdirSync } from "fs";
import path from "path";
const INPUT = "assets/raw-images"; // put your original images here
const OUTPUT = "assets/images";
mkdirSync(OUTPUT, { recursive: true });
const files = (await fg(`${INPUT}/*.{png,jpg,jpeg,webp}`)).sort();
for (let i = 0; i < files.length; i++) {
const out = path.join(OUTPUT, `${i}.png`);
await sharp(files[i]).resize(1024, 1024, { fit: "cover" }).toFile(out);
}
console.log("Normalized images written to", OUTPUT);
```
## Recommended Folder Structure
```markup
project/
assets/
images/
0.png
1.png
2.png
...
metadata/
0.json
1.json
2.json
...
scripts/
resize.js
pinata.ts (Pinata SDK init)
upload-images.ts (pin images folder → IMAGES_CID)
make-metadata.ts (generate JSON → uses IMAGES_CID)
upload-metadata.ts (pin metadata folder → METADATA_CID)
```
* `images/` contain consistent dimensions and formats (e.g., 1024×1024 PNG).
* `metadata/` contain one JSON per token ID, matching the filename (e.g., 7.json for tokenId 7).
## Upload Images to IPFS via Pinata
Install Pinata SDK:
```bash
npm i pinata dotenv
```
Initialise Pinata using your JWT credentials:
```typescript
// pinata.ts
import 'dotenv/config'
import { PinataSDK } from 'pinata'
export const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT!, // from Pinata “API Keys”
pinataGateway: process.env.PINATA_GATEWAY!, // e.g. myxyz.mypinata.cloud
})
```
Create a `.env` file in your project root:
```
PINATA_JWT=eyJhbGciOi... # your Pinata JWT (keep secret)
PINATA_GATEWAY=myxyz.mypinata.cloud
IMAGES_CID= # leave blank until you upload images
```
\
Upload the images directory and get the root CID:
```typescript
// scripts/upload-images.ts
import 'dotenv/config'
import { pinata } from './pinata'
import fs from 'node:fs/promises'
import path from 'node:path'
import { File } from 'node:buffer'
const DIR = 'assets/images'
async function main() {
const names = (await fs.readdir(DIR))
.filter(n => n.match(/\.(png|jpg|jpeg|webp)$/i))
.sort((a, b) => Number(a.split('.')[0]) - Number(b.split('.')[0]))
const files: File[] = []
for (const name of names) {
const bytes = await fs.readFile(path.join(DIR, name))
files.push(new File([bytes], name)) // uploaded as a single folder
}
const res = await pinata.upload.public.fileArray(files)
// res.cid is the directory root CID
console.log('Images CID:', res.cid)
}
main().catch(console.error)
```
Run the following command to upload the images:
```bash
node dist/scripts/upload-images.js
# => Images CID: bafybe...
```
Copy the CID into `.env` as `IMAGES_CID=....`<br>
## Generate Metadata JSON
Each \*.json should follow the de facto standards:
```json
{
"name": "NFTTest #0",
"description": "A clean ERC-721 on Somnia with per-token metadata.",
"image": "ipfs://IMAGES_CID/0.png",
"external_url": "https://your-site.example",
"attributes": [
{ "trait_type": "Edition", "value": 0 }
]
}
```
Install `fast-glob` library:
```bash
npm i fs-extra fast-glob
```
Run the script below to generate a file per image:
```typescript
/ scripts/make-metadata.js
import fg from "fast-glob";
import { writeJSON, mkdirs } from "fs-extra";
import path from "path";
const IMAGES_CID = process.env.IMAGES_CID || "bafy..."; // paste from Step 2
const OUT_DIR = "assets/metadata";
sync function main() {
await mkdirs(OUT_DIR)
const imgs = (await fg('assets/images/*.{png,jpg,jpeg,webp}')).sort((a, b) =>
Number(path.basename(a).split('.')[0]) - Number(path.basename(b).split('.')[0])
)
for (const p of imgs) {
const id = Number(path.basename(p).split('.')[0])
const json = {
name: `NFTTest #${id}`,
description: 'ERC-721 on Somnia with Pinata-hosted IPFS metadata.',
image: `ipfs://${IMAGES_CID}/${id}.png`,
attributes: [{ trait_type: 'Edition', value: id }]
}
await writeJSON(path.join(OUT_DIR, `${id}.json`), json, { spaces: 2 })
}
console.log('Metadata written to', OUT_DIR)
}
main().catch(console.error)
```
To create the metadata files, run the command:
```bash
node dist/scripts/make-metadata.js
```
## Upload Metadata to IPFS
Upload the metadata folder to generate the `METADATA_CID`
```typescript
// scripts/upload-metadata.ts
import { pinata } from './pinata'
import fs from 'node:fs/promises'
import path from 'node:path'
import { File } from 'node:buffer'
const DIR = 'assets/metadata'
async function main() {
const names = (await fs.readdir(DIR))
.filter(n => n.endsWith('.json'))
.sort((a, b) => Number(a.split('.')[0]) - Number(b.split('.')[0]))
const files: File[] = []
for (const name of names) {
const bytes = await fs.readFile(path.join(DIR, name))
files.push(new File([bytes], name, { type: 'application/json' }))
}
const res = await pinata.upload.public.fileArray(files)
console.log('Metadata CID:', res.cid)
}
main().catch(console.error)
```
Run the command:
```
node dist/scripts/upload-metadata.js
# => Metadata CID: bafybe...
```
Your tokenURI format is now: `ipfs://<METADATA_CID>/<tokenId>.json` Make sure the script uploads `assets/metadata` and records the printed Metadata CID; you’ll mint with: `ipfs://<METADATA_CID>/<tokenId>.json`
## NFT Smart Contract
We’ll use `ERC721URIStorage` Smart Contract and mint with full URIs. Paste this into Remix as `NFTTest.sol`
<details>
<summary>NFTTest.sol</summary>
```solidity
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.4.0
pragma solidity ^0.8.27;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract NFTTest is ERC721, ERC721URIStorage, Ownable {
uint256 private _nextTokenId;
constructor(address initialOwner)
ERC721("NFTTest", "NFTT")
Ownable(initialOwner)
{}
function _baseURI() internal pure override returns (string memory) {
return "https://ipfs.io"; // only affects relative paths
}
function safeMint(address to, string memory uri)
public
onlyOwner
returns (uint256)
{
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri); // store full URI (e.g., ipfs://CID/0.json)
return tokenId;
}
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
```
</details>
## Contract Walkthrough
### Imports
* `ERC721` is the core NFT logic (ownership, transfers, approvals).
* `ERC721URIStorage` adds per-token storage for URIs via `_setTokenURI`.
* `Ownable` simple admin model; exposes onlyOwner for minting control.
`ERC721URIStorage` is the most straightforward way to store a token’s exact URI. For large drops, consider using a baseURI pattern to save storage gas; otherwise, this is perfect for flexible, explicit URIs.
State (\_nextTokenId) is the sequential ID counter starting at 0. First mint → tokenId = 0, then 1, 2, … etc
### Constructor
Initializes collection name/symbol and sets the owner to `initialOwner` (the only account allowed to mint).
### \_baseURI()
Returns <https://ipfs.io>. This is only used if you mint with relative paths (e.g., /ipfs/CID/7.json). If you mint with absolute ipfs\://… URIs (recommended), this value is ignored.
### safeMint(to, uri):
Owner-only mint. Creates a new token ID, mints safely (checking receiver contracts implement IERC721Receiver), and stores the exact uri string for that token via \_setTokenURI.
### Compile and Deploy using Remix. Follow this [guide](/developer/building-dapps/tokens-and-nfts/create-erc20-tokens)
Copy the deployed contract address.<br>
## How To Mint NFTs (Per Token URIs)
n Remix (Deployed Contracts):
* Call `safeMint(to, uri)` where:
* `to` is the recipient address (can be yours).
* `uri` is `ipfs://<METADATA_CID>/<id>.json` (e.g., `ipfs://bafy.../0.json`).
Then call `tokenURI(0)` to confirm the stored URI.<br>
If you prefer scripts for multiple mints, you can write a script to Batch Mint using Hardhat, for example:
```typescript
scripts/mint.ts (Hardhat)
import { ethers } from "hardhat";
const CONTRACT = "0xYourDeployedAddress";
const RECEIVER = "0xReceiver";
const METADATA_CID = "bafy...";
async function main() {
const nft = await ethers.getContractAt("NFTTest", CONTRACT);
for (let id = 0; id < 10; id++) {
const uri = `ipfs://${METADATA_CID}/${id}.json`;
const tx = await nft.safeMint(RECEIVER, uri);
await tx.wait();
console.log(`Minted #${id} → ${uri}`);
}
}
main().catch(console.error);
```
## Validation
To validate everything end to end, first do quick gateway smoke tests by opening `https://ipfs.io/ipfs/<IMAGES_CID>/0.png` and `https://ipfs.io/ipfs/<METADATA_CID>/0.json` in a browser to confirm the image and JSON are reachable. Next, call `tokenURI(0)` on your contract and verify it returns `ipfs://<METADATA_CID>/0.json`. Finally, check in a wallet or marketplace UI that the NFT renders correctly using the `ipfs://` image URI embedded in the JSON.<br>
## MetadataOptional Fully Onchain Metadata
<details>
<summary>Smart Contract</summary>
If your art is small (SVG) or you only need text metadata, you can **encode metadata JSON on-chain**:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
contract OnchainMeta is ERC721, Ownable {
using Strings for uint256;
uint256 private _id;
constructor(address owner_) ERC721("Onchain", "ONC") Ownable(owner_) {}
function mint(address to) external onlyOwner returns (uint256) {
uint256 tokenId = _id++;
_safeMint(to, tokenId);
return tokenId;
}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
// Example: trivial, static image via SVG data URI
string memory image = string(
abi.encodePacked(
"data:image/svg+xml;base64,",
Base64.encode(
bytes(
string(
abi.encodePacked(
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'>",
"<rect width='512' height='512' fill='black'/>",
"<text x='50%' y='50%' dominant-baseline='middle' text-anchor='middle' fill='white' font-size='48'>#",
tokenId.toString(),
"</text></svg>"
)
)
)
)
)
);
bytes memory json = abi.encodePacked(
'{"name":"Onchain #', tokenId.toString(),
'","description":"Fully on-chain metadata","image":"', image, '"}'
);
return string(abi.encodePacked(
"data:application/json;base64,",
Base64.encode(json)
));
}
}
```
**Trade-offs**
* No external dependencies; URIs are immutable, always available.
* Higher gas (long strings), limited to compact media (SVG/text). For large images, prefer IPFS.
</details>
# Using Native Coin (SOMI/STT)
SOMI is the native coin of the Somnia Network, similar to ETH on Ethereum. Unlike ERC20 tokens, SOMI is built into the protocol itself and does not have a contract address.
{% hint style="warning" %}
Kindly note that the Native Coin for Somnia Testnet is STT.
{% endhint %}
{% embed url="<https://www.youtube.com/watch?v=pq7sARCSTSk>" %}
This multi-part guide shows how to use SOMI for:
* Payments
* Escrow
* Donations & Tipping
* Sponsored gas via Account Abstraction
## Use SOMI for Payments in Smart Contracts
A simple contract that accepts exact SOMI payments:
```solidity
function payToAccess() external payable {
require(msg.value == 0.01 ether, "Must send exactly 0.01 SOMI");
}
```
Use `msg.value` to access the native coin sent in a transaction. No ERC20 functions are needed.
To withdraw collected SOMI:
```solidity
function withdraw() external onlyOwner {
payable(owner).transfer(address(this).balance);
}
```
Deploy using Hardhat Ignition or Viem and test with a `sendTransaction` call.
<details>
<summary>Example.sol</summary>
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SOMIPayment {
address public owner;
constructor() {
owner = msg.sender;
}
// Modifier to restrict access to the contract owner
modifier onlyOwner() {
require(msg.sender == owner, "Only owner can call this");
_;
}
// User must send exactly 0.01 SOMI to access this feature
function payToAccess() external payable {
require(msg.value == 0.01 ether, "Must send exactly 0.01 SOMI");
// Logic for access: mint token, grant download, emit event, etc.
}
// Withdraw collected SOMI to owner
function withdraw() external onlyOwner {
payable(owner).transfer(address(this).balance);
}
}
```
</details>
## Build an SOMI Escrow Contract
A secure escrow contract allows a buyer to deposit SOMI and later release or refund:
```solidity
constructor(address payable _seller) payable {
buyer = msg.sender;
seller = _seller;
amount = msg.value;
}
```
Release funds to the seller:
```solidity
function release() external onlyBuyer {
seller.transfer(amount);
}
```
Deploy with Hardhat Ignition and pass SOMI as `value` during deployment.
<details>
<summary>Example.sol</summary>
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SOMIEscrow {
address public buyer;
address payable public seller;
uint256 public amount;
bool public isDeposited;
constructor(address payable _seller) payable {
buyer = msg.sender;
seller = _seller;
amount = msg.value;
require(amount > 0, "Must deposit SOMI");
isDeposited = true;
}
modifier onlyBuyer() {
require(msg.sender == buyer, "Only buyer can call this");
_;
}
function release() external onlyBuyer {
require(isDeposited, "No funds to release");
isDeposited = false;
seller.transfer(amount);
}
function refund() external onlyBuyer {
require(isDeposited, "No funds to refund");
isDeposited = false;
payable(buyer).transfer(amount);
}
}
```
</details>
## SOMI Tip Jar
Allow any wallet to send tips directly:
```solidity
receive() external payable {
emit Tipped(msg.sender, msg.value);
}
```
Withdraw all tips:
```solidity
function withdraw() external onlyOwner {
payable(owner).transfer(addr프로젝트로 돌아가기