From c3c40ba28bff060776e2f57e5b5f6be9f5dfd438 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:14:23 -0800 Subject: [PATCH 01/20] feat: add evm-nfts skill --- skills/evm-nfts/SKILL.md | 728 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 728 insertions(+) create mode 100644 skills/evm-nfts/SKILL.md diff --git a/skills/evm-nfts/SKILL.md b/skills/evm-nfts/SKILL.md new file mode 100644 index 0000000..714b941 --- /dev/null +++ b/skills/evm-nfts/SKILL.md @@ -0,0 +1,728 @@ +--- +name: evm-nfts +description: "ERC-721 and ERC-1155 NFT development patterns for EVM chains. Covers minting, metadata, royalties, marketplace integration with Seaport, and security pitfalls. Uses OpenZeppelin v5.6.1." +license: Apache-2.0 +metadata: + author: 0xinit + version: "1.0" + chain: multichain + category: NFT & Tokens +tags: + - erc-721 + - erc-1155 + - nft + - evm + - solidity + - metadata + - royalties + - erc-2981 + - seaport + - openzeppelin + - minting + - token +--- + +# EVM NFTs + +ERC-721 and ERC-1155 are the two NFT standards on EVM chains. ERC-721 represents unique tokens (1-of-1 art, PFPs, deeds), while ERC-1155 represents semi-fungible tokens (game items, editions, tickets). This skill covers secure minting patterns, metadata standards, royalty implementation, and marketplace integration using OpenZeppelin v5.6.1 and Seaport 1.6. + +## What You Probably Got Wrong + +- **`_safeMint` calls `onERC721Received` on the receiver -- reentrancy vector.** The `_safeMint` function makes an external call to the receiver if it is a contract. A malicious receiver contract can re-enter your mint function and mint more tokens than allowed. Use `ReentrancyGuard` on ALL mint functions, without exception. + +- **Allowlist signatures without EIP-712 domain separators are replayed across chains and contracts.** Always include `block.chainid` and `address(this)` in the domain separator. Signatures MUST include a per-address nonce (tracked onchain in a mapping) and a deadline (`block.timestamp` expiry). Without nonces, a single valid signature can be replayed indefinitely. + +- **`transferFrom` does NOT check `onERC721Received` -- only `safeTransferFrom` does.** If you send an ERC-721 token to a contract using `transferFrom`, the contract has no way to react or reject the transfer. The token can be permanently locked. Always use `safeTransferFrom` when the recipient might be a contract. + +- **Royalties (ERC-2981) are NOT enforced onchain.** ERC-2981 is a read-only interface. Marketplaces query `royaltyInfo()` and can choose to ignore it. For practical enforcement, use ERC-721C (Limit Break's transfer validator pattern) which hooks into transfer functions to enforce payment. + +- **ERC-721 has TWO independent approval mechanisms.** `approve(to, tokenId)` grants approval for a single token. `setApprovalForAll(operator, true)` grants blanket approval for all tokens. These are independent -- revoking one does not affect the other. Users commonly forget `setApprovalForAll` remains active after individual approvals are cleared. + +- **`tokenURI` returns a URI that resolves to JSON metadata, not a URL to an image.** The URI points to a JSON document with `name`, `description`, `image`, and optional `attributes`. The `image` field inside that JSON is the actual image URL. + +- **ERC-1155 has no `name()` or `symbol()` in the standard.** Use `uri(id)` to get the metadata URI for a specific token ID. OpenZeppelin's ERC1155 implementation does not expose name/symbol by default. + +- **`{id}` substitution in ERC-1155 URIs is client-side.** The `uri()` function returns a template like `https://api.example.com/token/{id}.json`. The `{id}` placeholder must be replaced by the client with the hex token ID, zero-padded to 64 characters, lowercase, no `0x` prefix. Example: token ID 1 becomes `0000000000000000000000000000000000000000000000000000000000000001`. + +- **`balanceOf` returns a count, not token IDs.** For ERC-721, getting the list of owned token IDs requires `tokenOfOwnerByIndex` (only available if the contract extends ERC721Enumerable). Without enumeration, you must index Transfer events off-chain. + +- **`tokenURI` returns empty string for non-existent tokens in some implementations.** OpenZeppelin v5.6.1's ERC721URIStorage returns empty string if the token has not been minted. It does NOT revert. Always check `_ownerOf(tokenId) != address(0)` before returning metadata if you want to revert for non-existent tokens. + +- **Seaport 1.6 is the current marketplace protocol.** Same deterministic address on all EVM chains: `0x0000000000000068F116A894984e2DB1123eB395`. Do not use Seaport 1.4 or 1.5 -- they have known issues. + +- **Reservoir is DEPRECATED.** Reservoir shut down in October 2025. Use Seaport directly or the OpenSea API for marketplace integration. + +## OpenZeppelin v5.6.1 Patterns + +OpenZeppelin v5 introduced breaking changes from v4. All code in this skill targets v5.6.1. + +| v4 Pattern (BROKEN) | v5.6.1 Pattern (CORRECT) | +|---------------------|-------------------------| +| `using Counters for Counters.Counter` | Use `uint256 private _nextTokenId` directly | +| `_beforeTokenTransfer` / `_afterTokenTransfer` | Override `_update(to, tokenId, auth)` | +| `Ownable()` (no args) | `Ownable(initialOwner)` -- owner is required in constructor | +| `ERC721("Name", "Symbol")` only | Same, but URI storage auto-returns from `tokenURI()` | +| `_safeMint(to, tokenId)` then `_setTokenURI` | Same pattern, but `_update` is the hook point | +| `_exists(tokenId)` | `_ownerOf(tokenId) != address(0)` | + +```bash +forge install OpenZeppelin/openzeppelin-contracts@v5.6.1 +``` + +## ERC-721 + +### Interface + +```solidity +interface IERC721 { + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + function balanceOf(address owner) external view returns (uint256); + function ownerOf(uint256 tokenId) external view returns (address); + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; + function safeTransferFrom(address from, address to, uint256 tokenId) external; + function transferFrom(address from, address to, uint256 tokenId) external; + function approve(address to, uint256 tokenId) external; + function setApprovalForAll(address operator, bool approved) external; + function getApproved(uint256 tokenId) external view returns (address); + function isApprovedForAll(address owner, address operator) external view returns (bool); +} +``` + +### Minimal ERC-721 with Mint (OpenZeppelin v5.6.1) + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +contract SimpleNFT is ERC721, Ownable, ReentrancyGuard { + uint256 private _nextTokenId; + uint256 public constant MAX_SUPPLY = 10_000; + + error MaxSupplyReached(); + + constructor(address initialOwner) + ERC721("SimpleNFT", "SNFT") + Ownable(initialOwner) + {} + + function mint(address to) external onlyOwner nonReentrant { + uint256 tokenId = _nextTokenId++; + if (tokenId >= MAX_SUPPLY) revert MaxSupplyReached(); + _safeMint(to, tokenId); + } + + function totalSupply() external view returns (uint256) { + return _nextTokenId; + } +} +``` + +### ERC721Enumerable + +Adds `tokenOfOwnerByIndex` and `tokenByIndex` for on-chain enumeration. Increases gas cost for transfers by ~50%. Only use when on-chain enumeration is a hard requirement. + +```solidity +import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; + +contract EnumerableNFT is ERC721Enumerable, Ownable, ReentrancyGuard { + uint256 private _nextTokenId; + + constructor(address initialOwner) + ERC721("EnumerableNFT", "ENFT") + Ownable(initialOwner) + {} + + function _update(address to, uint256 tokenId, address auth) + internal + override(ERC721Enumerable) + returns (address) + { + return super._update(to, tokenId, auth); + } + + function _increaseBalance(address account, uint128 value) + internal + override(ERC721Enumerable) + { + super._increaseBalance(account, value); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721Enumerable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} +``` + +## ERC-1155 + +### Interface + +```solidity +interface IERC1155 { + event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value); + event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values); + event ApprovalForAll(address indexed account, address indexed operator, bool approved); + event URI(string value, uint256 indexed id); + + function balanceOf(address account, uint256 id) external view returns (uint256); + function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids) external view returns (uint256[] memory); + function setApprovalForAll(address operator, bool approved) external; + function isApprovedForAll(address account, address operator) external view returns (bool); + function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) external; + function safeBatchTransferFrom(address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data) external; +} +``` + +### ERC-1155 with Supply Tracking + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import {ERC1155Supply} from "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +contract GameItems is ERC1155, ERC1155Supply, Ownable, ReentrancyGuard { + uint256 public constant SWORD = 0; + uint256 public constant SHIELD = 1; + uint256 public constant POTION = 2; + + mapping(uint256 id => uint256 cap) public maxSupply; + + error ExceedsMaxSupply(uint256 id); + + constructor(address initialOwner) + ERC1155("https://api.example.com/items/{id}.json") + Ownable(initialOwner) + { + maxSupply[SWORD] = 1_000; + maxSupply[SHIELD] = 5_000; + maxSupply[POTION] = 100_000; + } + + function mint(address to, uint256 id, uint256 amount) external onlyOwner nonReentrant { + if (totalSupply(id) + amount > maxSupply[id]) revert ExceedsMaxSupply(id); + _mint(to, id, amount, ""); + } + + function mintBatch(address to, uint256[] calldata ids, uint256[] calldata amounts) + external + onlyOwner + nonReentrant + { + for (uint256 i = 0; i < ids.length; i++) { + if (totalSupply(ids[i]) + amounts[i] > maxSupply[ids[i]]) revert ExceedsMaxSupply(ids[i]); + } + _mintBatch(to, ids, amounts, ""); + } + + function _update(address from, address to, uint256[] memory ids, uint256[] memory values) + internal + override(ERC1155, ERC1155Supply) + { + super._update(from, to, ids, values); + } +} +``` + +## Metadata Standards + +### Metadata JSON Schema (ERC-721) + +```json +{ + "name": "Token #1", + "description": "Description of the token", + "image": "ipfs://Qm.../1.png", + "external_url": "https://example.com/token/1", + "attributes": [ + { "trait_type": "Color", "value": "Red" }, + { "trait_type": "Level", "value": 5, "display_type": "number" }, + { "trait_type": "Power", "value": 85, "max_value": 100 } + ] +} +``` + +### Metadata Storage Comparison + +| Storage | Cost | Immutability | Speed | Best For | +|---------|------|-------------|-------|----------| +| IPFS (pinned) | Low (~$5/GB/year via Pinata/nft.storage) | Content-addressed, immutable if pinned | Moderate (gateway dependent) | Most collections, art, PFPs | +| Arweave | One-time (~$5/GB permanent) | Permanent, truly immutable | Moderate | Archival, high-value art | +| Onchain SVG | High (~50k-200k gas per token) | Fully onchain, chain-immutable | Instant (no external dependency) | Generative art, dynamic NFTs | +| Centralized API | Cheapest | Mutable, server-dependent | Fast | Game items, evolving metadata | + +### Contract-Level Metadata (contractURI) + +OpenSea and other marketplaces read `contractURI()` for collection-level metadata: + +```solidity +function contractURI() external pure returns (string memory) { + return "ipfs://QmCollectionMetadataHash"; +} +``` + +The JSON at that URI: + +```json +{ + "name": "Collection Name", + "description": "Collection description", + "image": "ipfs://QmCollectionImage", + "external_link": "https://example.com", + "seller_fee_basis_points": 500, + "fee_recipient": "0xRoyaltyRecipient..." +} +``` + +### ERC-4906: Metadata Update Events + +Signal marketplaces to refresh metadata for specific tokens or ranges: + +```solidity +import {IERC4906} from "@openzeppelin/contracts/interfaces/IERC4906.sol"; + +contract UpdatableNFT is ERC721, IERC4906 { + function updateMetadata(uint256 tokenId) external onlyOwner { + emit MetadataUpdate(tokenId); + } + + function updateAllMetadata() external onlyOwner { + emit BatchMetadataUpdate(0, type(uint256).max); + } +} +``` + +## Royalties (ERC-2981) + +ERC-2981 defines a standard `royaltyInfo(tokenId, salePrice)` function that returns the royalty recipient and amount. Marketplaces query this but are NOT required to enforce it. + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +contract RoyaltyNFT is ERC721, ERC2981, Ownable, ReentrancyGuard { + uint256 private _nextTokenId; + + constructor(address initialOwner, address royaltyReceiver) + ERC721("RoyaltyNFT", "RNFT") + Ownable(initialOwner) + { + // 5% royalty (500 basis points) on all tokens by default + _setDefaultRoyalty(royaltyReceiver, 500); + } + + function mint(address to) external onlyOwner nonReentrant { + _safeMint(to, _nextTokenId++); + } + + /// @notice Override for specific token royalty + function setTokenRoyalty(uint256 tokenId, address receiver, uint96 feeNumerator) + external + onlyOwner + { + _setTokenRoyalty(tokenId, receiver, feeNumerator); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721, ERC2981) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} +``` + +## Common Minting Patterns + +### Allowlist with Merkle Proof + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +contract MerkleAllowlistNFT is ERC721, Ownable, ReentrancyGuard { + uint256 private _nextTokenId; + bytes32 public merkleRoot; + mapping(address minter => bool claimed) public hasClaimed; + + error AlreadyClaimed(); + error InvalidProof(); + + constructor(address initialOwner, bytes32 _merkleRoot) + ERC721("AllowlistNFT", "ANFT") + Ownable(initialOwner) + { + merkleRoot = _merkleRoot; + } + + function allowlistMint(bytes32[] calldata proof) external nonReentrant { + if (hasClaimed[msg.sender]) revert AlreadyClaimed(); + + bytes32 leaf = keccak256(abi.encodePacked(msg.sender)); + if (!MerkleProof.verify(proof, merkleRoot, leaf)) revert InvalidProof(); + + hasClaimed[msg.sender] = true; + _safeMint(msg.sender, _nextTokenId++); + } +} +``` + +### Allowlist with EIP-712 Signature (Nonce + Deadline) + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @notice EIP-712 signed allowlist with per-address nonce and deadline to prevent replay +contract SignedAllowlistNFT is ERC721, EIP712, Ownable, ReentrancyGuard { + using ECDSA for bytes32; + + uint256 private _nextTokenId; + address public signer; + mapping(address minter => uint256 nonce) public nonces; + + // EIP-712 domain includes contract address and chainId automatically via EIP712 base + bytes32 private constant MINT_TYPEHASH = + keccak256("Mint(address minter,uint256 nonce,uint256 deadline)"); + + error InvalidSignature(); + error SignatureExpired(); + + constructor(address initialOwner, address _signer) + ERC721("SignedNFT", "SGNFT") + EIP712("SignedAllowlistNFT", "1") + Ownable(initialOwner) + { + signer = _signer; + } + + function allowlistMint(uint256 deadline, bytes calldata signature) external nonReentrant { + if (block.timestamp > deadline) revert SignatureExpired(); + + uint256 currentNonce = nonces[msg.sender]; + + bytes32 structHash = keccak256( + abi.encode(MINT_TYPEHASH, msg.sender, currentNonce, deadline) + ); + bytes32 digest = _hashTypedDataV4(structHash); + address recovered = digest.recover(signature); + + if (recovered != signer) revert InvalidSignature(); + + // Increment nonce BEFORE external call (_safeMint) -- CEI pattern + nonces[msg.sender] = currentNonce + 1; + _safeMint(msg.sender, _nextTokenId++); + } +} +``` + +### Dutch Auction + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @notice Price decreases linearly from startPrice to endPrice over the auction duration +contract DutchAuctionNFT is ERC721, Ownable, ReentrancyGuard { + uint256 private _nextTokenId; + uint256 public constant MAX_SUPPLY = 10_000; + + uint256 public immutable auctionStart; + uint256 public immutable auctionDuration; + uint256 public immutable startPrice; + uint256 public immutable endPrice; + + error AuctionNotStarted(); + error MaxSupplyReached(); + error InsufficientPayment(); + + constructor( + address initialOwner, + uint256 _auctionStart, + uint256 _auctionDuration, + uint256 _startPrice, + uint256 _endPrice + ) + ERC721("DutchAuctionNFT", "DANFT") + Ownable(initialOwner) + { + auctionStart = _auctionStart; + auctionDuration = _auctionDuration; + startPrice = _startPrice; + endPrice = _endPrice; + } + + function currentPrice() public view returns (uint256) { + if (block.timestamp < auctionStart) revert AuctionNotStarted(); + uint256 elapsed = block.timestamp - auctionStart; + if (elapsed >= auctionDuration) return endPrice; + + uint256 priceDrop = ((startPrice - endPrice) * elapsed) / auctionDuration; + return startPrice - priceDrop; + } + + function mint() external payable nonReentrant { + uint256 price = currentPrice(); + if (msg.value < price) revert InsufficientPayment(); + uint256 tokenId = _nextTokenId++; + if (tokenId >= MAX_SUPPLY) revert MaxSupplyReached(); + + _safeMint(msg.sender, tokenId); + + uint256 refund = msg.value - price; + if (refund > 0) { + (bool sent, ) = payable(msg.sender).call{value: refund}(""); + require(sent); + } + } + + function withdraw() external onlyOwner { + (bool sent, ) = payable(owner()).call{value: address(this).balance}(""); + require(sent); + } +} +``` + +### Commit-Reveal for High-Value Mints + +Prevents front-running and sniping by splitting mint into two transactions: commit (hash of intent) then reveal (actual mint after delay). + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @notice Two-phase mint: commit a hash, then reveal after a delay to prevent front-running +contract CommitRevealNFT is ERC721, Ownable, ReentrancyGuard { + uint256 private _nextTokenId; + uint256 public constant REVEAL_DELAY = 2; + uint256 public constant REVEAL_WINDOW = 256; + + struct Commitment { + uint64 blockNumber; + bool revealed; + } + + mapping(bytes32 commitHash => Commitment) public commitments; + + error CommitmentAlreadyExists(); + error CommitmentNotFound(); + error RevealTooEarly(); + error RevealWindowExpired(); + error AlreadyRevealed(); + + constructor(address initialOwner) + ERC721("CommitRevealNFT", "CRNFT") + Ownable(initialOwner) + {} + + /// @notice Phase 1: submit keccak256(abi.encodePacked(msg.sender, salt)) + function commit(bytes32 commitHash) external { + if (commitments[commitHash].blockNumber != 0) revert CommitmentAlreadyExists(); + commitments[commitHash] = Commitment({ + blockNumber: uint64(block.number), + revealed: false + }); + } + + /// @notice Phase 2: reveal with original salt after REVEAL_DELAY blocks + function reveal(bytes32 salt) external nonReentrant { + bytes32 commitHash = keccak256(abi.encodePacked(msg.sender, salt)); + Commitment storage c = commitments[commitHash]; + + if (c.blockNumber == 0) revert CommitmentNotFound(); + if (c.revealed) revert AlreadyRevealed(); + if (block.number < c.blockNumber + REVEAL_DELAY) revert RevealTooEarly(); + // blockhash only available for last 256 blocks + if (block.number > c.blockNumber + REVEAL_WINDOW) revert RevealWindowExpired(); + + c.revealed = true; + _safeMint(msg.sender, _nextTokenId++); + } +} +``` + +### Free Claim (One Per Address) + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +contract FreeClaimNFT is ERC721, Ownable, ReentrancyGuard { + uint256 private _nextTokenId; + uint256 public constant MAX_SUPPLY = 5_000; + bool public claimActive; + + mapping(address claimer => bool claimed) public hasClaimed; + + error ClaimNotActive(); + error AlreadyClaimed(); + error MaxSupplyReached(); + + constructor(address initialOwner) + ERC721("FreeClaimNFT", "FREE") + Ownable(initialOwner) + {} + + function setClaimActive(bool active) external onlyOwner { + claimActive = active; + } + + function claim() external nonReentrant { + if (!claimActive) revert ClaimNotActive(); + if (hasClaimed[msg.sender]) revert AlreadyClaimed(); + uint256 tokenId = _nextTokenId++; + if (tokenId >= MAX_SUPPLY) revert MaxSupplyReached(); + + hasClaimed[msg.sender] = true; + _safeMint(msg.sender, tokenId); + } +} +``` + +## Marketplace Integration (Seaport 1.6) + +> **Last verified:** March 2026 + +Seaport 1.6 is OpenSea's marketplace protocol, deployed at the same deterministic address on all EVM chains: `0x0000000000000068F116A894984e2DB1123eB395`. + +### Approving Seaport + +Before listing, the NFT owner must approve Seaport as an operator: + +```solidity +nftContract.setApprovalForAll(0x0000000000000068F116A894984e2DB1123eB395, true); +``` + +### Creating a Listing (TypeScript with viem) + +```typescript +import { createWalletClient, http, type Address } from "viem"; +import { mainnet } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +const SEAPORT = "0x0000000000000068F116A894984e2DB1123eB395" as const; + +// Seaport order components -- ERC-721 listing for ETH +interface OrderParameters { + offerer: Address; + zone: Address; + offer: Array<{ + itemType: number; // 2 = ERC721, 3 = ERC1155 + token: Address; + identifierOrCriteria: bigint; + startAmount: bigint; + endAmount: bigint; + }>; + consideration: Array<{ + itemType: number; // 0 = ETH, 1 = ERC20 + token: Address; + identifierOrCriteria: bigint; + startAmount: bigint; + endAmount: bigint; + recipient: Address; + }>; + orderType: number; // 0 = FULL_OPEN + startTime: bigint; + endTime: bigint; + zoneHash: `0x${string}`; + salt: bigint; + conduitKey: `0x${string}`; + totalOriginalConsiderationItems: bigint; +} +``` + +See `examples/marketplace-listing/` for a complete working example with order signing and fulfillment. + +### Seaport Item Types + +| Value | Type | Description | +|-------|------|-------------| +| 0 | NATIVE | ETH (or native token) | +| 1 | ERC20 | ERC-20 token | +| 2 | ERC721 | ERC-721 NFT | +| 3 | ERC1155 | ERC-1155 token | +| 4 | ERC721_WITH_CRITERIA | ERC-721 with trait-based criteria | +| 5 | ERC1155_WITH_CRITERIA | ERC-1155 with criteria | + +## Security Checklist for NFT Contracts + +- [ ] `ReentrancyGuard` on ALL mint functions (`_safeMint` makes external calls) +- [ ] Supply cap enforced with `require(tokenId < MAX_SUPPLY)` or equivalent +- [ ] Per-wallet mint limit to prevent single-wallet hoarding +- [ ] Commit-reveal for high-value mints to prevent front-running/sniping +- [ ] Metadata freeze function (`emit BatchMetadataUpdate` then disable further changes) +- [ ] Never use `tx.origin` for authorization -- always `msg.sender` +- [ ] EIP-712 domain separators on all signature-based allowlists +- [ ] Nonce tracking for signature-based mints to prevent replay +- [ ] Deadline/expiry on all signed messages +- [ ] `Ownable` with explicit initial owner (OZ v5 requires constructor arg) +- [ ] `supportsInterface` correctly overridden when combining ERC721 + ERC2981 + ERC4906 +- [ ] Withdrawal function for ETH from paid mints (owner-only, pull pattern) +- [ ] No hardcoded royalty recipient if it needs to be updatable + +## Related Skills + +- **openzeppelin** -- contract library used for all implementations in this skill +- **solidity-security** -- comprehensive Solidity security patterns and audit checklist +- **eip-reference** -- detailed EIP specifications including ERC-721, ERC-1155, ERC-2981 +- **foundry** -- testing and deployment framework for Solidity contracts +- **viem** -- TypeScript library for EVM interaction used in marketplace examples + +## References + +- [ERC-721: Non-Fungible Token Standard](https://eips.ethereum.org/EIPS/eip-721) +- [ERC-1155: Multi Token Standard](https://eips.ethereum.org/EIPS/eip-1155) +- [ERC-2981: NFT Royalty Standard](https://eips.ethereum.org/EIPS/eip-2981) +- [ERC-4906: EIP-721 Metadata Update Extension](https://eips.ethereum.org/EIPS/eip-4906) +- [ERC-6551: Non-fungible Token Bound Accounts](https://eips.ethereum.org/EIPS/eip-6551) +- [OpenZeppelin Contracts v5 Docs](https://docs.openzeppelin.com/contracts/5.x/) +- [OpenZeppelin v5 Migration Guide](https://docs.openzeppelin.com/contracts/5.x/upgradeable#migration) +- [Seaport Protocol Documentation](https://docs.opensea.io/reference/seaport-overview) +- [Seaport 1.6 Source (ProjectOpenSea)](https://github.com/ProjectOpenSea/seaport) +- [OpenSea Metadata Standards](https://docs.opensea.io/docs/metadata-standards) +- [Limit Break ERC-721C](https://github.com/limitbreakinc/creator-token-standards) From f61387d865de53f68be118f6a82d63832cfdb9a4 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:47:51 -0800 Subject: [PATCH 02/20] feat: add evm-nfts examples --- .../examples/erc1155-game-items/README.md | 258 ++++++++++++++++++ .../evm-nfts/examples/erc721-mint/README.md | 255 +++++++++++++++++ .../examples/marketplace-listing/README.md | 245 +++++++++++++++++ .../examples/onchain-metadata/README.md | 210 ++++++++++++++ 4 files changed, 968 insertions(+) create mode 100644 skills/evm-nfts/examples/erc1155-game-items/README.md create mode 100644 skills/evm-nfts/examples/erc721-mint/README.md create mode 100644 skills/evm-nfts/examples/marketplace-listing/README.md create mode 100644 skills/evm-nfts/examples/onchain-metadata/README.md diff --git a/skills/evm-nfts/examples/erc1155-game-items/README.md b/skills/evm-nfts/examples/erc1155-game-items/README.md new file mode 100644 index 0000000..a4cbe8d --- /dev/null +++ b/skills/evm-nfts/examples/erc1155-game-items/README.md @@ -0,0 +1,258 @@ +# ERC-1155 Game Items with Batch Mint + +Complete ERC-1155 game items contract with per-item supply caps, batch minting, and role-based access. Uses OpenZeppelin v5.6.1. + +## Dependencies + +```bash +forge install OpenZeppelin/openzeppelin-contracts@v5.6.1 +``` + +## Contract + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import {ERC1155Supply} from "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Supply.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/// @notice Game items with per-ID supply caps, batch mint, and role-based minting +contract GameItems is ERC1155, ERC1155Supply, AccessControl, ReentrancyGuard { + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + uint256 public constant SWORD = 0; + uint256 public constant SHIELD = 1; + uint256 public constant POTION = 2; + uint256 public constant ARMOR = 3; + uint256 public constant RING = 4; + + mapping(uint256 id => uint256 cap) public maxSupply; + string public name; + string public symbol; + + error ExceedsMaxSupply(uint256 id, uint256 requested, uint256 available); + error ArrayLengthMismatch(); + + constructor(address admin, string memory baseURI) + ERC1155(baseURI) + { + name = "GameItems"; + symbol = "GITM"; + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(MINTER_ROLE, admin); + + maxSupply[SWORD] = 1_000; + maxSupply[SHIELD] = 5_000; + maxSupply[POTION] = 100_000; + maxSupply[ARMOR] = 2_000; + maxSupply[RING] = 500; + } + + function mint(address to, uint256 id, uint256 amount) + external + onlyRole(MINTER_ROLE) + nonReentrant + { + uint256 available = maxSupply[id] - totalSupply(id); + if (amount > available) revert ExceedsMaxSupply(id, amount, available); + _mint(to, id, amount, ""); + } + + function mintBatch(address to, uint256[] calldata ids, uint256[] calldata amounts) + external + onlyRole(MINTER_ROLE) + nonReentrant + { + if (ids.length != amounts.length) revert ArrayLengthMismatch(); + + for (uint256 i = 0; i < ids.length; i++) { + uint256 available = maxSupply[ids[i]] - totalSupply(ids[i]); + if (amounts[i] > available) revert ExceedsMaxSupply(ids[i], amounts[i], available); + } + + _mintBatch(to, ids, amounts, ""); + } + + function setURI(string calldata newURI) external onlyRole(DEFAULT_ADMIN_ROLE) { + _setURI(newURI); + } + + function setMaxSupply(uint256 id, uint256 cap) external onlyRole(DEFAULT_ADMIN_ROLE) { + maxSupply[id] = cap; + } + + /// @notice contractURI for marketplace collection metadata + function contractURI() external pure returns (string memory) { + return "https://api.example.com/contract-metadata.json"; + } + + function _update(address from, address to, uint256[] memory ids, uint256[] memory values) + internal + override(ERC1155, ERC1155Supply) + { + super._update(from, to, ids, values); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC1155, AccessControl) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} +``` + +## Foundry Test + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {GameItems} from "../src/GameItems.sol"; + +contract GameItemsTest is Test { + GameItems items; + address admin = makeAddr("admin"); + address player = makeAddr("player"); + + function setUp() public { + items = new GameItems(admin, "https://api.example.com/items/{id}.json"); + } + + function test_mint() public { + vm.prank(admin); + items.mint(player, items.SWORD(), 1); + assertEq(items.balanceOf(player, items.SWORD()), 1); + assertEq(items.totalSupply(items.SWORD()), 1); + } + + function test_batchMint() public { + uint256[] memory ids = new uint256[](3); + ids[0] = items.SWORD(); + ids[1] = items.SHIELD(); + ids[2] = items.POTION(); + + uint256[] memory amounts = new uint256[](3); + amounts[0] = 1; + amounts[1] = 2; + amounts[2] = 10; + + vm.prank(admin); + items.mintBatch(player, ids, amounts); + + assertEq(items.balanceOf(player, items.SWORD()), 1); + assertEq(items.balanceOf(player, items.SHIELD()), 2); + assertEq(items.balanceOf(player, items.POTION()), 10); + } + + function test_revert_exceedsMaxSupply() public { + vm.prank(admin); + vm.expectRevert( + abi.encodeWithSelector(GameItems.ExceedsMaxSupply.selector, items.RING(), 501, 500) + ); + items.mint(player, items.RING(), 501); + } + + function test_revert_unauthorized() public { + vm.prank(player); + vm.expectRevert(); + items.mint(player, items.SWORD(), 1); + } + + function test_balanceOfBatch() public { + vm.prank(admin); + items.mint(player, items.SWORD(), 3); + + address[] memory accounts = new address[](2); + accounts[0] = player; + accounts[1] = admin; + + uint256[] memory ids = new uint256[](2); + ids[0] = items.SWORD(); + ids[1] = items.SWORD(); + + uint256[] memory balances = items.balanceOfBatch(accounts, ids); + assertEq(balances[0], 3); + assertEq(balances[1], 0); + } +} +``` + +## TypeScript Integration + +```typescript +import { createPublicClient, http, getContract, type Address } from "viem"; +import { mainnet } from "viem/chains"; + +const GAME_ITEMS_ADDRESS: Address = "0x..."; + +const gameItemsAbi = [ + { + type: "function", + name: "balanceOf", + inputs: [ + { name: "account", type: "address" }, + { name: "id", type: "uint256" }, + ], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "balanceOfBatch", + inputs: [ + { name: "accounts", type: "address[]" }, + { name: "ids", type: "uint256[]" }, + ], + outputs: [{ type: "uint256[]" }], + stateMutability: "view", + }, + { + type: "function", + name: "uri", + inputs: [{ name: "id", type: "uint256" }], + outputs: [{ type: "string" }], + stateMutability: "view", + }, +] as const; + +const publicClient = createPublicClient({ + chain: mainnet, + transport: http(process.env.RPC_URL), +}); + +const contract = getContract({ + address: GAME_ITEMS_ADDRESS, + abi: gameItemsAbi, + client: publicClient, +}); + +async function getPlayerInventory(player: Address) { + const ids = [0n, 1n, 2n, 3n, 4n]; + const accounts = ids.map(() => player); + + const balances = await contract.read.balanceOfBatch([accounts, ids]); + + const ITEM_NAMES = ["Sword", "Shield", "Potion", "Armor", "Ring"]; + return ids.map((id, i) => ({ + id, + name: ITEM_NAMES[Number(id)], + balance: balances[i], + })); +} +``` + +## Notes + +- `ERC1155Supply` tracks total supply per token ID. Override `_update` to combine ERC1155 and ERC1155Supply. +- `{id}` in the URI template is replaced client-side with hex token ID (64 chars, zero-padded, no 0x prefix). +- `name` and `symbol` are not part of the ERC-1155 standard but are exposed here for marketplace compatibility. +- `AccessControl` is used instead of `Ownable` for granular role management. The `MINTER_ROLE` can be granted to backend services or game servers. +- `ReentrancyGuard` is on both `mint` and `mintBatch` because ERC-1155 mint calls `onERC1155Received`/`onERC1155BatchReceived` on the receiver. diff --git a/skills/evm-nfts/examples/erc721-mint/README.md b/skills/evm-nfts/examples/erc721-mint/README.md new file mode 100644 index 0000000..0413ef1 --- /dev/null +++ b/skills/evm-nfts/examples/erc721-mint/README.md @@ -0,0 +1,255 @@ +# ERC-721 Collection with Allowlist Mint + +Complete ERC-721 collection with Merkle proof allowlist, per-wallet limit, supply cap, royalties, and ReentrancyGuard. Uses OpenZeppelin v5.6.1. + +## Dependencies + +```bash +forge install OpenZeppelin/openzeppelin-contracts@v5.6.1 +``` + +## Contract + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +/// @notice ERC-721 collection with Merkle allowlist, per-wallet cap, and ERC-2981 royalties +contract AllowlistCollection is ERC721, ERC721URIStorage, ERC2981, Ownable, ReentrancyGuard { + using Strings for uint256; + + uint256 private _nextTokenId; + uint256 public constant MAX_SUPPLY = 10_000; + uint256 public constant MAX_PER_WALLET = 3; + uint256 public constant ALLOWLIST_PRICE = 0.05 ether; + uint256 public constant PUBLIC_PRICE = 0.08 ether; + + bytes32 public merkleRoot; + string private _baseTokenURI; + bool public allowlistActive; + bool public publicMintActive; + + mapping(address minter => uint256 count) public mintCount; + + error MaxSupplyReached(); + error ExceedsWalletLimit(); + error InsufficientPayment(); + error MintNotActive(); + error InvalidProof(); + + constructor( + address initialOwner, + address royaltyReceiver, + bytes32 _merkleRoot, + string memory baseURI + ) + ERC721("AllowlistCollection", "ALC") + Ownable(initialOwner) + { + merkleRoot = _merkleRoot; + _baseTokenURI = baseURI; + _setDefaultRoyalty(royaltyReceiver, 500); + } + + function allowlistMint(uint256 quantity, bytes32[] calldata proof) + external + payable + nonReentrant + { + if (!allowlistActive) revert MintNotActive(); + if (msg.value < ALLOWLIST_PRICE * quantity) revert InsufficientPayment(); + if (mintCount[msg.sender] + quantity > MAX_PER_WALLET) revert ExceedsWalletLimit(); + + bytes32 leaf = keccak256(abi.encodePacked(msg.sender)); + if (!MerkleProof.verify(proof, merkleRoot, leaf)) revert InvalidProof(); + + mintCount[msg.sender] += quantity; + + for (uint256 i = 0; i < quantity; i++) { + uint256 tokenId = _nextTokenId++; + if (tokenId >= MAX_SUPPLY) revert MaxSupplyReached(); + _safeMint(msg.sender, tokenId); + } + } + + function publicMint(uint256 quantity) external payable nonReentrant { + if (!publicMintActive) revert MintNotActive(); + if (msg.value < PUBLIC_PRICE * quantity) revert InsufficientPayment(); + if (mintCount[msg.sender] + quantity > MAX_PER_WALLET) revert ExceedsWalletLimit(); + + mintCount[msg.sender] += quantity; + + for (uint256 i = 0; i < quantity; i++) { + uint256 tokenId = _nextTokenId++; + if (tokenId >= MAX_SUPPLY) revert MaxSupplyReached(); + _safeMint(msg.sender, tokenId); + } + } + + function setAllowlistActive(bool active) external onlyOwner { + allowlistActive = active; + } + + function setPublicMintActive(bool active) external onlyOwner { + publicMintActive = active; + } + + function setMerkleRoot(bytes32 _merkleRoot) external onlyOwner { + merkleRoot = _merkleRoot; + } + + function setBaseURI(string calldata baseURI) external onlyOwner { + _baseTokenURI = baseURI; + } + + function totalSupply() external view returns (uint256) { + return _nextTokenId; + } + + function withdraw() external onlyOwner { + (bool sent, ) = payable(owner()).call{value: address(this).balance}(""); + require(sent); + } + + function tokenURI(uint256 tokenId) + public + view + override(ERC721, ERC721URIStorage) + returns (string memory) + { + return super.tokenURI(tokenId); + } + + function _baseURI() internal view override returns (string memory) { + return _baseTokenURI; + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721, ERC721URIStorage, ERC2981) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} +``` + +## Generate Merkle Root (TypeScript) + +```bash +npm install @openzeppelin/merkle-tree +``` + +```typescript +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; +import { keccak256, encodePacked } from "viem"; + +const allowlist: `0x${string}`[] = [ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + "0x3333333333333333333333333333333333333333", +]; + +const leaves = allowlist.map((addr) => [addr]); +const tree = StandardMerkleTree.of(leaves, ["address"]); + +console.log("Merkle Root:", tree.root); + +function getProof(address: `0x${string}`): string[] { + for (const [i, v] of tree.entries()) { + if (v[0].toLowerCase() === address.toLowerCase()) { + return tree.getProof(i); + } + } + throw new Error("Address not in allowlist"); +} + +const proof = getProof("0x1111111111111111111111111111111111111111"); +console.log("Proof:", proof); +``` + +## Foundry Test + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {AllowlistCollection} from "../src/AllowlistCollection.sol"; +import {Merkle} from "murky/Merkle.sol"; + +contract AllowlistCollectionTest is Test { + AllowlistCollection nft; + Merkle merkle; + address owner = makeAddr("owner"); + address royaltyReceiver = makeAddr("royalty"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + + bytes32[] leaves; + bytes32 root; + + function setUp() public { + merkle = new Merkle(); + leaves = new bytes32[](2); + leaves[0] = keccak256(abi.encodePacked(alice)); + leaves[1] = keccak256(abi.encodePacked(bob)); + root = merkle.getRoot(leaves); + + nft = new AllowlistCollection(owner, royaltyReceiver, root, "ipfs://base/"); + vm.prank(owner); + nft.setAllowlistActive(true); + } + + function test_allowlistMint() public { + bytes32[] memory proof = merkle.getProof(leaves, 0); + vm.deal(alice, 1 ether); + vm.prank(alice); + nft.allowlistMint{value: 0.05 ether}(1, proof); + + assertEq(nft.ownerOf(0), alice); + assertEq(nft.totalSupply(), 1); + } + + function test_revert_invalidProof() public { + bytes32[] memory proof = merkle.getProof(leaves, 1); + vm.deal(alice, 1 ether); + vm.prank(alice); + vm.expectRevert(AllowlistCollection.InvalidProof.selector); + nft.allowlistMint{value: 0.05 ether}(1, proof); + } + + function test_revert_exceedsWalletLimit() public { + bytes32[] memory proof = merkle.getProof(leaves, 0); + vm.deal(alice, 1 ether); + vm.startPrank(alice); + nft.allowlistMint{value: 0.15 ether}(3, proof); + vm.expectRevert(AllowlistCollection.ExceedsWalletLimit.selector); + nft.allowlistMint{value: 0.05 ether}(1, proof); + vm.stopPrank(); + } + + function test_royaltyInfo() public { + (address receiver, uint256 amount) = nft.royaltyInfo(0, 1 ether); + assertEq(receiver, royaltyReceiver); + assertEq(amount, 0.05 ether); + } +} +``` + +## Notes + +- The Merkle tree uses `keccak256(abi.encodePacked(address))` as the leaf hash. Match this exactly in your off-chain generation. +- `ReentrancyGuard` is on both `allowlistMint` and `publicMint` because `_safeMint` makes an external call to the receiver. +- Per-wallet limit uses `mintCount` mapping. This tracks across both allowlist and public phases. +- `ERC721URIStorage` auto-returns the concatenation of `_baseURI() + tokenId` in `tokenURI()`. +- Install Murky for Merkle tree testing in Foundry: `forge install dmfxyz/murky`. diff --git a/skills/evm-nfts/examples/marketplace-listing/README.md b/skills/evm-nfts/examples/marketplace-listing/README.md new file mode 100644 index 0000000..517028c --- /dev/null +++ b/skills/evm-nfts/examples/marketplace-listing/README.md @@ -0,0 +1,245 @@ +# NFT Marketplace Listing with Seaport 1.6 + +Complete TypeScript example for listing an ERC-721 NFT on Seaport 1.6, creating a signed order, and fulfilling it. + +## Dependencies + +```bash +npm install viem @opensea/seaport-js +``` + +## Approve Seaport + +Before listing, the NFT owner must approve Seaport as an operator. This is a one-time operation per collection. + +```typescript +import { + createPublicClient, + createWalletClient, + http, + parseEther, + type Address, +} from "viem"; +import { mainnet } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +const SEAPORT_ADDRESS = "0x0000000000000068F116A894984e2DB1123eB395" as const; + +const account = privateKeyToAccount( + process.env.PRIVATE_KEY as `0x${string}` +); + +const publicClient = createPublicClient({ + chain: mainnet, + transport: http(process.env.RPC_URL), +}); + +const walletClient = createWalletClient({ + account, + chain: mainnet, + transport: http(process.env.RPC_URL), +}); + +const nftAbi = [ + { + type: "function", + name: "setApprovalForAll", + inputs: [ + { name: "operator", type: "address" }, + { name: "approved", type: "bool" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "isApprovedForAll", + inputs: [ + { name: "owner", type: "address" }, + { name: "operator", type: "address" }, + ], + outputs: [{ type: "bool" }], + stateMutability: "view", + }, +] as const; + +async function approveSeaport(nftAddress: Address) { + const isApproved = await publicClient.readContract({ + address: nftAddress, + abi: nftAbi, + functionName: "isApprovedForAll", + args: [account.address, SEAPORT_ADDRESS], + }); + + if (!isApproved) { + const hash = await walletClient.writeContract({ + address: nftAddress, + abi: nftAbi, + functionName: "setApprovalForAll", + args: [SEAPORT_ADDRESS, true], + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status === "reverted") { + throw new Error("Approval transaction reverted"); + } + } +} +``` + +## Create Listing with Seaport SDK + +```typescript +import { Seaport } from "@opensea/seaport-js"; +import { ethers } from "ethers"; + +const provider = new ethers.JsonRpcProvider(process.env.RPC_URL); +const signer = new ethers.Wallet( + process.env.PRIVATE_KEY as string, + provider +); + +const seaport = new Seaport(signer); + +async function createListing( + nftAddress: Address, + tokenId: bigint, + priceEth: string, + durationSeconds: number +) { + const now = Math.floor(Date.now() / 1000); + + const { executeAllActions } = await seaport.createOrder({ + offer: [ + { + itemType: 2, // ERC721 + token: nftAddress, + identifier: tokenId.toString(), + }, + ], + consideration: [ + { + amount: ethers.parseEther(priceEth).toString(), + recipient: signer.address, + }, + ], + startTime: now.toString(), + endTime: (now + durationSeconds).toString(), + }); + + const order = await executeAllActions(); + return order; +} +``` + +## Fulfill Order (Buyer) + +```typescript +async function fulfillListing(order: any) { + const { executeAllActions } = await seaport.fulfillOrder({ + order, + accountAddress: signer.address, + }); + + const transaction = await executeAllActions(); + return transaction; +} +``` + +## Direct Seaport Interaction (Without SDK) + +For environments where the Seaport SDK is not available, interact directly with the contract: + +```typescript +const seaportAbi = [ + { + type: "function", + name: "fulfillBasicOrder_efficient_6GL6yc", + inputs: [ + { + name: "parameters", + type: "tuple", + components: [ + { name: "considerationToken", type: "address" }, + { name: "considerationIdentifier", type: "uint256" }, + { name: "considerationAmount", type: "uint256" }, + { name: "offerer", type: "address" }, + { name: "zone", type: "address" }, + { name: "offerToken", type: "address" }, + { name: "offerIdentifier", type: "uint256" }, + { name: "offerAmount", type: "uint256" }, + { name: "basicOrderType", type: "uint8" }, + { name: "startTime", type: "uint256" }, + { name: "endTime", type: "uint256" }, + { name: "zoneHash", type: "bytes32" }, + { name: "salt", type: "uint256" }, + { name: "offererConduitKey", type: "bytes32" }, + { name: "fulfillerConduitKey", type: "bytes32" }, + { name: "totalOriginalAdditionalRecipients", type: "uint256" }, + { name: "additionalRecipients", type: "tuple[]", components: [ + { name: "amount", type: "uint256" }, + { name: "recipient", type: "address" }, + ]}, + { name: "signature", type: "bytes" }, + ], + }, + ], + outputs: [{ name: "fulfilled", type: "bool" }], + stateMutability: "payable", + }, +] as const; + +async function fulfillBasicOrder( + offerer: Address, + nftAddress: Address, + tokenId: bigint, + price: bigint, + signature: `0x${string}`, + startTime: bigint, + endTime: bigint, + salt: bigint +) { + const hash = await walletClient.writeContract({ + address: SEAPORT_ADDRESS, + abi: seaportAbi, + functionName: "fulfillBasicOrder_efficient_6GL6yc", + args: [{ + considerationToken: "0x0000000000000000000000000000000000000000", + considerationIdentifier: 0n, + considerationAmount: price, + offerer, + zone: "0x0000000000000000000000000000000000000000", + offerToken: nftAddress, + offerIdentifier: tokenId, + offerAmount: 1n, + basicOrderType: 0, // ETH_TO_ERC721_FULL_OPEN + startTime, + endTime, + zoneHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + salt, + offererConduitKey: "0x0000000000000000000000000000000000000000000000000000000000000000", + fulfillerConduitKey: "0x0000000000000000000000000000000000000000000000000000000000000000", + totalOriginalAdditionalRecipients: 0n, + additionalRecipients: [], + signature, + }], + value: price, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status === "reverted") { + throw new Error("Fulfillment transaction reverted"); + } + + return receipt; +} +``` + +## Notes + +- Seaport 1.6 address `0x0000000000000068F116A894984e2DB1123eB395` is the same on all EVM chains (Ethereum, Arbitrum, Base, Optimism, Polygon, etc.). +- Orders are signed off-chain (EIP-712) and fulfilled on-chain. No gas is spent until someone fulfills the order. +- `basicOrderType: 0` is `ETH_TO_ERC721_FULL_OPEN` -- ETH payment for a full ERC-721 token, no zone restrictions. +- For ERC-1155 listings, use `itemType: 3` in the offer and `basicOrderType: 4` (`ETH_TO_ERC1155_FULL_OPEN`). +- The `@opensea/seaport-js` SDK uses ethers.js internally. For pure viem projects, use the direct contract interaction pattern. +- Never hardcode private keys. Use environment variables or a wallet connection for signing. diff --git a/skills/evm-nfts/examples/onchain-metadata/README.md b/skills/evm-nfts/examples/onchain-metadata/README.md new file mode 100644 index 0000000..1956510 --- /dev/null +++ b/skills/evm-nfts/examples/onchain-metadata/README.md @@ -0,0 +1,210 @@ +# Fully Onchain SVG NFT + +Complete onchain SVG NFT with Base64-encoded metadata. No external storage dependencies -- all metadata and artwork live in the smart contract. + +## Dependencies + +```bash +forge install OpenZeppelin/openzeppelin-contracts@v5.6.1 +``` + +## Contract + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +/// @notice Fully onchain SVG NFT -- metadata and image stored in contract bytecode +contract OnchainSVG is ERC721, Ownable, ReentrancyGuard { + using Strings for uint256; + using Strings for uint160; + + uint256 private _nextTokenId; + uint256 public constant MAX_SUPPLY = 1_000; + + error MaxSupplyReached(); + + constructor(address initialOwner) + ERC721("OnchainShapes", "SHAPE") + Ownable(initialOwner) + {} + + function mint() external nonReentrant { + uint256 tokenId = _nextTokenId++; + if (tokenId >= MAX_SUPPLY) revert MaxSupplyReached(); + _safeMint(msg.sender, tokenId); + } + + function tokenURI(uint256 tokenId) public view override returns (string memory) { + address owner = _ownerOf(tokenId); + require(owner != address(0), "Token does not exist"); + + string memory svg = _generateSVG(tokenId); + string memory attributes = _generateAttributes(tokenId); + + string memory json = string.concat( + '{"name":"Shape #', + tokenId.toString(), + '","description":"Fully onchain generative shape","image":"data:image/svg+xml;base64,', + Base64.encode(bytes(svg)), + '","attributes":', + attributes, + "}" + ); + + return string.concat("data:application/json;base64,", Base64.encode(bytes(json))); + } + + function _generateSVG(uint256 tokenId) internal pure returns (string memory) { + // Deterministic pseudorandom seed from tokenId + uint256 seed = uint256(keccak256(abi.encodePacked(tokenId))); + + string memory bgColor = _pickColor(seed, 0); + string memory shapeColor = _pickColor(seed, 1); + uint256 cx = 50 + (seed % 200); + uint256 cy = 50 + ((seed >> 8) % 200); + uint256 r = 30 + ((seed >> 16) % 70); + + return string.concat( + '', + '', + '', + "" + ); + } + + function _generateAttributes(uint256 tokenId) internal pure returns (string memory) { + uint256 seed = uint256(keccak256(abi.encodePacked(tokenId))); + uint256 r = 30 + ((seed >> 16) % 70); + + string memory size; + if (r < 50) size = "Small"; + else if (r < 75) size = "Medium"; + else size = "Large"; + + return string.concat( + '[{"trait_type":"Size","value":"', + size, + '"},{"trait_type":"Radius","value":', + r.toString(), + ',"display_type":"number"}]' + ); + } + + function _pickColor(uint256 seed, uint256 offset) internal pure returns (string memory) { + string[8] memory palette = [ + "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", + "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F" + ]; + return palette[(seed >> (offset * 32)) % 8]; + } + + function totalSupply() external view returns (uint256) { + return _nextTokenId; + } +} +``` + +## Foundry Test + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test, console} from "forge-std/Test.sol"; +import {OnchainSVG} from "../src/OnchainSVG.sol"; + +contract OnchainSVGTest is Test { + OnchainSVG nft; + address owner = makeAddr("owner"); + address alice = makeAddr("alice"); + + function setUp() public { + nft = new OnchainSVG(owner); + } + + function test_mint() public { + vm.prank(alice); + nft.mint(); + assertEq(nft.ownerOf(0), alice); + assertEq(nft.totalSupply(), 1); + } + + function test_tokenURI_returnsBase64Json() public { + vm.prank(alice); + nft.mint(); + + string memory uri = nft.tokenURI(0); + // URI must start with data:application/json;base64, + assertTrue(bytes(uri).length > 35); + + bytes memory prefix = bytes("data:application/json;base64,"); + for (uint256 i = 0; i < prefix.length; i++) { + assertEq(bytes(uri)[i], prefix[i]); + } + } + + function test_differentTokens_differentSVG() public { + vm.startPrank(alice); + nft.mint(); + nft.mint(); + vm.stopPrank(); + + string memory uri0 = nft.tokenURI(0); + string memory uri1 = nft.tokenURI(1); + + assertTrue( + keccak256(bytes(uri0)) != keccak256(bytes(uri1)), + "Different tokens should produce different URIs" + ); + } + + function test_revert_nonexistentToken() public { + vm.expectRevert("Token does not exist"); + nft.tokenURI(999); + } +} +``` + +## How It Works + +1. **`tokenURI` returns a data URI.** Instead of pointing to IPFS or a server, the function returns `data:application/json;base64,`. Marketplaces and wallets decode this inline. + +2. **JSON contains an inline SVG.** The `image` field is `data:image/svg+xml;base64,`. The SVG is generated deterministically from the token ID. + +3. **Deterministic randomness.** Each token's visual properties (colors, position, size) are derived from `keccak256(tokenId)`. The same token ID always produces the same image. + +4. **No external dependencies.** The metadata and artwork are fully onchain. The NFT survives IPFS unpinning, server shutdowns, and API deprecations. + +## Gas Considerations + +| Operation | Approximate Gas | +|-----------|----------------| +| Mint | ~85k gas | +| `tokenURI` read | ~50k gas (view, no cost) | +| Deployment | ~1.5M gas | + +Onchain SVG is gas-intensive for deployment and complex generation. Keep SVG simple (under 2KB) to avoid hitting block gas limits on `tokenURI` calls in other contracts. + +## Notes + +- `Base64.encode` from OpenZeppelin v5.6.1 handles the encoding. Do not use a custom Base64 library. +- `string.concat` (Solidity 0.8.12+) is cleaner and cheaper than `abi.encodePacked` for string concatenation. +- Attributes follow the OpenSea metadata standard: `trait_type` + `value`, with optional `display_type` for numeric traits. +- For more complex generative art, consider storing SVG fragments as contract constants and composing them based on seed bits. From edc1587db3ab67aacd6ef64118a14d4ec7074e15 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:12:08 -0800 Subject: [PATCH 03/20] feat: add evm-nfts docs and resources --- skills/evm-nfts/docs/advanced-patterns.md | 217 ++++++++++++++++++ skills/evm-nfts/docs/troubleshooting.md | 119 ++++++++++ .../evm-nfts/resources/interface-reference.md | 193 ++++++++++++++++ .../resources/marketplace-addresses.md | 92 ++++++++ skills/evm-nfts/resources/metadata-schema.md | 132 +++++++++++ 5 files changed, 753 insertions(+) create mode 100644 skills/evm-nfts/docs/advanced-patterns.md create mode 100644 skills/evm-nfts/docs/troubleshooting.md create mode 100644 skills/evm-nfts/resources/interface-reference.md create mode 100644 skills/evm-nfts/resources/marketplace-addresses.md create mode 100644 skills/evm-nfts/resources/metadata-schema.md diff --git a/skills/evm-nfts/docs/advanced-patterns.md b/skills/evm-nfts/docs/advanced-patterns.md new file mode 100644 index 0000000..cc8daeb --- /dev/null +++ b/skills/evm-nfts/docs/advanced-patterns.md @@ -0,0 +1,217 @@ +# Advanced NFT Patterns + +## ERC-6551: Token Bound Accounts + +ERC-6551 turns any ERC-721 NFT into a smart contract account. Each NFT gets its own address that can hold assets (ETH, ERC-20 tokens, other NFTs), interact with protocols, and build an on-chain identity tied to the token. + +### How It Works + +A singleton registry contract deployed at a deterministic address creates account instances for any ERC-721 token. The account address is deterministic based on the implementation, chain ID, token contract, token ID, and salt. + +``` +NFT (ERC-721) + | + v +Registry.createAccount(implementation, chainId, tokenContract, tokenId, salt) + | + v +Token Bound Account (TBA) -- a smart contract at a deterministic address + | + v +Can hold ETH, ERC-20s, ERC-721s, ERC-1155s, sign messages, call contracts +``` + +### Registry Interface + +> **Last verified:** March 2026 + +| Contract | Address | Chains | +|----------|---------|--------| +| ERC-6551 Registry | `0x000000006551c19487814612e58FE06813775758` | All EVM chains | + +```solidity +interface IERC6551Registry { + event ERC6551AccountCreated( + address account, + address indexed implementation, + bytes32 salt, + uint256 chainId, + address indexed tokenContract, + uint256 indexed tokenId + ); + + function createAccount( + address implementation, + bytes32 salt, + uint256 chainId, + address tokenContract, + uint256 tokenId + ) external returns (address account); + + function account( + address implementation, + bytes32 salt, + uint256 chainId, + address tokenContract, + uint256 tokenId + ) external view returns (address account); +} +``` + +### Account Interface + +```solidity +interface IERC6551Account { + receive() external payable; + + function token() + external + view + returns (uint256 chainId, address tokenContract, uint256 tokenId); + + function state() external view returns (uint256); + + function isValidSigner(address signer, bytes calldata context) + external + view + returns (bytes4 magicValue); +} + +interface IERC6551Executable { + function execute(address to, uint256 value, bytes calldata data, uint8 operation) + external + payable + returns (bytes memory); +} +``` + +### Use Cases + +| Use Case | Description | +|----------|-------------| +| NFT inventory | Game character NFT holds its equipment (other NFTs) and in-game currency | +| On-chain identity | PFP NFT accumulates reputation, credentials, and transaction history | +| Bundle trading | Sell an NFT along with all assets it holds in a single transfer | +| Loyalty programs | Membership NFT accumulates rewards directly | +| Composable DeFi | NFT representing a position holds the actual LP tokens | + +### Key Considerations + +- **Ownership follows the NFT.** When an ERC-721 token is transferred, the new owner controls the token bound account and all its assets. This is the core value proposition but also a risk: anyone with approval to transfer the NFT can steal the TBA's contents. +- **Circular ownership is invalid.** A TBA cannot own the NFT that controls it (directly or through a chain). Implementations must guard against this. +- **Account address is deterministic.** You can compute the TBA address before it is deployed, using `registry.account(...)`. This lets you send assets to the TBA before creating it. + +## ERC-721C: Creator Token Standards (Royalty Enforcement) + +ERC-721C, created by Limit Break, provides practical on-chain royalty enforcement by hooking into transfer functions. Unlike ERC-2981 (which is advisory), ERC-721C can block transfers that do not route through royalty-paying channels. + +### How It Works + +ERC-721C uses a transfer validator contract that is called on every transfer. The validator maintains a whitelist of allowed operators (marketplaces that honor royalties) and can block transfers initiated by non-compliant operators. + +``` +Transfer attempt + | + v +_update() override calls TransferValidator.validateTransfer() + | + v +Validator checks: Is the operator (marketplace) on the whitelist? + | + +--> Yes: Transfer proceeds, royalties will be paid by the marketplace + | + +--> No: Transfer reverts +``` + +### Transfer Validator Interface + +```solidity +interface ITransferValidator { + function validateTransfer( + address caller, + address from, + address to, + uint256 tokenId + ) external view; +} +``` + +### Integration Pattern + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +interface ITransferValidator { + function validateTransfer( + address caller, address from, address to, uint256 tokenId + ) external view; +} + +/// @notice ERC-721 with enforced royalties via transfer validation +contract EnforcedRoyaltyNFT is ERC721, ERC2981, Ownable { + ITransferValidator public transferValidator; + + error TransferValidatorNotSet(); + + constructor(address initialOwner, address _transferValidator, address royaltyReceiver) + ERC721("EnforcedRoyaltyNFT", "ERNFT") + Ownable(initialOwner) + { + transferValidator = ITransferValidator(_transferValidator); + _setDefaultRoyalty(royaltyReceiver, 500); + } + + function _update(address to, uint256 tokenId, address auth) + internal + override + returns (address from) + { + from = super._update(to, tokenId, auth); + + // Skip validation for mints (from == address(0)) and burns (to == address(0)) + if (from != address(0) && to != address(0)) { + if (address(transferValidator) == address(0)) revert TransferValidatorNotSet(); + transferValidator.validateTransfer(msg.sender, from, to, tokenId); + } + } + + function setTransferValidator(address _transferValidator) external onlyOwner { + transferValidator = ITransferValidator(_transferValidator); + } + + function supportsInterface(bytes4 interfaceId) + public view override(ERC721, ERC2981) returns (bool) + { + return super.supportsInterface(interfaceId); + } +} +``` + +### Tradeoffs + +| Aspect | ERC-2981 (Advisory) | ERC-721C (Enforced) | +|--------|--------------------|--------------------| +| Marketplace compliance | Optional | Required (or transfer blocked) | +| User friction | None | Cannot use non-compliant marketplaces | +| Creator revenue certainty | Low | High | +| Decentralization | Permissionless transfers | Operator whitelist required | +| Gas overhead | None | ~5k gas per transfer (validator call) | +| Adoption | Universal standard | Growing but not universal | + +### Key Considerations + +- **Whitelist maintenance.** The transfer validator whitelist must be actively maintained. New marketplaces need to be added, and compromised/non-compliant ones removed. This is an ongoing operational burden. +- **P2P transfers.** Direct wallet-to-wallet transfers (where `msg.sender == from`) are typically allowed without validator checks. Only operator-initiated transfers (marketplace sales) are validated. +- **Ecosystem fragmentation.** Some users and marketplaces reject enforced royalties as a violation of token ownership rights. Consider your audience and ecosystem norms. + +## References + +- [ERC-6551: Non-fungible Token Bound Accounts](https://eips.ethereum.org/EIPS/eip-6551) +- [ERC-6551 Reference Implementation](https://github.com/erc6551/reference) +- [Limit Break Creator Token Standards](https://github.com/limitbreakinc/creator-token-standards) +- [Tokenbound SDK](https://docs.tokenbound.org) diff --git a/skills/evm-nfts/docs/troubleshooting.md b/skills/evm-nfts/docs/troubleshooting.md new file mode 100644 index 0000000..c6624ac --- /dev/null +++ b/skills/evm-nfts/docs/troubleshooting.md @@ -0,0 +1,119 @@ +# EVM NFTs Troubleshooting Guide + +Common issues and solutions when developing ERC-721 and ERC-1155 NFT contracts and integrating with marketplaces. + +## Metadata Not Showing on OpenSea + +**Symptoms:** +- Collection page shows blank images and "Unnamed" tokens +- Individual token pages show no attributes + +**Solutions:** + +1. **`tokenURI` returns wrong format.** OpenSea expects `tokenURI(tokenId)` to return a URI pointing to a JSON document (not the image URL directly). Verify by calling `tokenURI` and checking the response is valid JSON with `name`, `description`, and `image` fields. + +2. **IPFS gateway timeout.** If using `ipfs://` URIs, OpenSea resolves through its own gateway. Pin content to multiple IPFS providers (Pinata, nft.storage, Infura) to ensure availability. Test with `https://ipfs.io/ipfs/` first. + +3. **Missing `contractURI()`.** Collection-level metadata (name, description, image) comes from `contractURI()`. Without it, the collection page is blank. Return a URI pointing to a JSON document with `name`, `description`, `image`, and `external_link`. + +4. **Refresh required.** OpenSea caches metadata aggressively. Use the "Refresh metadata" button on individual items, or call the OpenSea API: + ```bash + curl -X POST "https://api.opensea.io/api/v2/chain/ethereum/contract/
/nfts//refresh" \ + -H "X-API-KEY: $OPENSEA_API_KEY" + ``` + +5. **ERC-4906 events missing.** After updating metadata, emit `MetadataUpdate(tokenId)` or `BatchMetadataUpdate(fromTokenId, toTokenId)`. Marketplaces listen for these events to trigger re-indexing. + +## Gas Estimation Failure on Mint + +**Symptoms:** +- `estimateGas` reverts with no useful error message +- "execution reverted" without reason string + +**Solutions:** + +1. **Supply cap reached.** If `_nextTokenId >= MAX_SUPPLY`, the transaction reverts. Custom errors (e.g., `MaxSupplyReached()`) provide better error messages than require strings. + +2. **Mint not active.** Check if there is a `mintActive` or `claimActive` flag that must be set by the owner before minting. + +3. **Allowlist check failing.** For Merkle-based allowlists, verify the proof is generated against the correct root and the address is in the allowlist. For signature-based allowlists, verify the signer address matches, the nonce is current, and the deadline has not passed. + +4. **Insufficient payment.** For paid mints, `msg.value` must meet or exceed the current price. Check `currentPrice()` on Dutch auction contracts immediately before submitting. + +## Revert on `_safeMint` to Contract Address + +**Symptoms:** +- Mint succeeds for EOAs but reverts when minting to a contract +- Error: `ERC721InvalidReceiver` + +**Solutions:** + +1. **Receiver contract does not implement `IERC721Receiver`.** The `_safeMint` function calls `onERC721Received` on the receiver. If the receiver contract does not implement this interface, the call reverts. Implement the interface: + ```solidity + import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + + contract MyContract is IERC721Receiver { + function onERC721Received(address, address, uint256, bytes calldata) + external pure returns (bytes4) + { + return IERC721Receiver.onERC721Received.selector; + } + } + ``` + +2. **Receiver returns wrong selector.** The function must return `IERC721Receiver.onERC721Received.selector` (bytes4 `0x150b7a02`). Returning any other value or reverting causes the mint to fail. + +## ERC-1155 `{id}` URI Substitution Not Working + +**Symptoms:** +- API returns 404 for token metadata +- Marketplace shows no metadata for ERC-1155 tokens + +**Solutions:** + +1. **Server-side substitution attempted.** The `{id}` in the URI is a client-side substitution marker defined in the ERC-1155 spec. The contract returns the raw template with `{id}` literal. Clients replace `{id}` with the hex token ID, zero-padded to 64 characters, lowercase, no `0x` prefix. + + Example for token ID `1`: + ``` + Template: https://api.example.com/items/{id}.json + Resolved: https://api.example.com/items/0000000000000000000000000000000000000000000000000000000000000001.json + ``` + +2. **API endpoint mismatch.** Ensure your API handles both the padded hex format (per spec) and optionally the decimal format as a fallback. Most marketplaces follow the spec and use hex. + +3. **Wrong `uri()` return.** Verify the contract's `uri(id)` function returns the correct template string. In OZ v5, the base URI is set in the constructor and `uri()` returns it for all IDs. + +## `supportsInterface` Returns False for ERC-2981 + +**Symptoms:** +- Royalties not recognized by marketplaces +- `supportsInterface(0x2a55205a)` returns `false` + +**Solutions:** + +1. **Missing override.** When combining multiple inheritance (ERC721 + ERC2981), you must override `supportsInterface`: + ```solidity + function supportsInterface(bytes4 interfaceId) + public view override(ERC721, ERC2981) returns (bool) + { + return super.supportsInterface(interfaceId); + } + ``` + +2. **Interface ID check.** ERC-2981 interface ID is `0x2a55205a`. Verify with: + ```bash + cast sig "royaltyInfo(uint256,uint256)" + # Returns 0x2a55205a + ``` + +## Debug Checklist + +- [ ] `tokenURI(tokenId)` returns valid URI pointing to JSON (not image URL) +- [ ] JSON metadata has `name`, `description`, `image` fields +- [ ] `image` URL is accessible (test in browser) +- [ ] `contractURI()` implemented for collection metadata +- [ ] `supportsInterface` overridden for all inherited interfaces +- [ ] `ReentrancyGuard` on mint functions +- [ ] ERC-4906 events emitted on metadata changes +- [ ] IPFS content pinned to multiple providers +- [ ] ERC-1155 API handles hex-padded token IDs diff --git a/skills/evm-nfts/resources/interface-reference.md b/skills/evm-nfts/resources/interface-reference.md new file mode 100644 index 0000000..5ceeef0 --- /dev/null +++ b/skills/evm-nfts/resources/interface-reference.md @@ -0,0 +1,193 @@ +# NFT Interface Reference + +Complete interface signatures for ERC-721, ERC-1155, and ERC-2981. Use these for ABI encoding, type checking, and interface detection. + +## IERC721 + +```solidity +interface IERC721 { + // Events + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + // View functions + function balanceOf(address owner) external view returns (uint256 balance); + function ownerOf(uint256 tokenId) external view returns (address owner); + function getApproved(uint256 tokenId) external view returns (address operator); + function isApprovedForAll(address owner, address operator) external view returns (bool); + + // Transfer functions + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; + function safeTransferFrom(address from, address to, uint256 tokenId) external; + function transferFrom(address from, address to, uint256 tokenId) external; + + // Approval functions + function approve(address to, uint256 tokenId) external; + function setApprovalForAll(address operator, bool approved) external; +} + +// ERC-165 interface ID: 0x80ac58cd +``` + +## IERC721Metadata + +```solidity +interface IERC721Metadata { + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function tokenURI(uint256 tokenId) external view returns (string memory); +} + +// ERC-165 interface ID: 0x5b5e139f +``` + +## IERC721Enumerable + +```solidity +interface IERC721Enumerable { + function totalSupply() external view returns (uint256); + function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256); + function tokenByIndex(uint256 index) external view returns (uint256); +} + +// ERC-165 interface ID: 0x780e9d63 +``` + +## IERC721Receiver + +```solidity +interface IERC721Receiver { + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); + + // Must return: 0x150b7a02 +} +``` + +## IERC1155 + +```solidity +interface IERC1155 { + // Events + event TransferSingle( + address indexed operator, address indexed from, address indexed to, + uint256 id, uint256 value + ); + event TransferBatch( + address indexed operator, address indexed from, address indexed to, + uint256[] ids, uint256[] values + ); + event ApprovalForAll(address indexed account, address indexed operator, bool approved); + event URI(string value, uint256 indexed id); + + // View functions + function balanceOf(address account, uint256 id) external view returns (uint256); + function balanceOfBatch( + address[] calldata accounts, uint256[] calldata ids + ) external view returns (uint256[] memory); + function isApprovedForAll(address account, address operator) external view returns (bool); + + // Transfer functions + function safeTransferFrom( + address from, address to, uint256 id, uint256 amount, bytes calldata data + ) external; + function safeBatchTransferFrom( + address from, address to, uint256[] calldata ids, + uint256[] calldata amounts, bytes calldata data + ) external; + + // Approval + function setApprovalForAll(address operator, bool approved) external; +} + +// ERC-165 interface ID: 0xd9b67a26 +``` + +## IERC1155MetadataURI + +```solidity +interface IERC1155MetadataURI { + function uri(uint256 id) external view returns (string memory); +} + +// ERC-165 interface ID: 0x0e89341c +``` + +## IERC1155Receiver + +```solidity +interface IERC1155Receiver { + function onERC1155Received( + address operator, address from, uint256 id, + uint256 value, bytes calldata data + ) external returns (bytes4); + + function onERC1155BatchReceived( + address operator, address from, uint256[] calldata ids, + uint256[] calldata values, bytes calldata data + ) external returns (bytes4); + + // onERC1155Received must return: 0xf23a6e61 + // onERC1155BatchReceived must return: 0xbc197c81 +} +``` + +## IERC2981 + +```solidity +interface IERC2981 { + /// @notice Returns royalty info for a given token and sale price + /// @param tokenId The NFT asset queried for royalty information + /// @param salePrice The sale price of the NFT (in the payment token's base units) + /// @return receiver Address to receive royalty payment + /// @return royaltyAmount Amount of royalty payment in same units as salePrice + function royaltyInfo(uint256 tokenId, uint256 salePrice) + external + view + returns (address receiver, uint256 royaltyAmount); +} + +// ERC-165 interface ID: 0x2a55205a +``` + +## IERC4906 + +```solidity +interface IERC4906 { + event MetadataUpdate(uint256 _tokenId); + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); +} + +// ERC-165 interface ID: 0x49064906 +``` + +## Interface ID Quick Reference + +| Standard | Interface ID | Function | +|----------|-------------|----------| +| ERC-721 | `0x80ac58cd` | Core NFT | +| ERC-721 Metadata | `0x5b5e139f` | `name`, `symbol`, `tokenURI` | +| ERC-721 Enumerable | `0x780e9d63` | `totalSupply`, `tokenByIndex` | +| ERC-1155 | `0xd9b67a26` | Multi-token | +| ERC-1155 Metadata URI | `0x0e89341c` | `uri` | +| ERC-2981 | `0x2a55205a` | Royalties | +| ERC-4906 | `0x49064906` | Metadata updates | +| ERC-165 | `0x01ffc9a7` | Interface detection | + +## Verification + +```bash +# Check if a contract supports ERC-721 +cast call $CONTRACT "supportsInterface(bytes4)(bool)" 0x80ac58cd --rpc-url $RPC_URL + +# Check ERC-2981 support +cast call $CONTRACT "supportsInterface(bytes4)(bool)" 0x2a55205a --rpc-url $RPC_URL + +# Query royalty info (tokenId=0, salePrice=1 ETH) +cast call $CONTRACT "royaltyInfo(uint256,uint256)(address,uint256)" 0 1000000000000000000 --rpc-url $RPC_URL +``` diff --git a/skills/evm-nfts/resources/marketplace-addresses.md b/skills/evm-nfts/resources/marketplace-addresses.md new file mode 100644 index 0000000..7cad89a --- /dev/null +++ b/skills/evm-nfts/resources/marketplace-addresses.md @@ -0,0 +1,92 @@ +# Marketplace Contract Addresses + +> **Last verified:** March 2026 + +## Seaport 1.6 + +Seaport 1.6 is deployed at a deterministic address via CREATE2. The same address is valid on every EVM chain. + +| Chain | Address | Status | +|-------|---------|--------| +| Ethereum | `0x0000000000000068F116A894984e2DB1123eB395` | Active | +| Arbitrum | `0x0000000000000068F116A894984e2DB1123eB395` | Active | +| Base | `0x0000000000000068F116A894984e2DB1123eB395` | Active | +| Optimism | `0x0000000000000068F116A894984e2DB1123eB395` | Active | +| Polygon | `0x0000000000000068F116A894984e2DB1123eB395` | Active | +| Avalanche | `0x0000000000000068F116A894984e2DB1123eB395` | Active | +| BSC | `0x0000000000000068F116A894984e2DB1123eB395` | Active | +| Sepolia | `0x0000000000000068F116A894984e2DB1123eB395` | Active | + +## Seaport Conduit Controller + +| Chain | Address | Status | +|-------|---------|--------| +| All EVM | `0x00000000F9490004C11Cef243f5400493c00Ad63` | Active | + +## OpenSea API Endpoints + +| Network | Base URL | +|---------|----------| +| Ethereum Mainnet | `https://api.opensea.io/api/v2` | +| Polygon | `https://api.opensea.io/api/v2` | +| Arbitrum | `https://api.opensea.io/api/v2` | +| Base | `https://api.opensea.io/api/v2` | +| Optimism | `https://api.opensea.io/api/v2` | +| Sepolia (testnet) | `https://testnets-api.opensea.io/api/v2` | + +All mainnet requests use the same base URL with chain specified in the path (e.g., `/chain/ethereum/...`). + +### Common API Calls + +```bash +# Get NFT metadata +curl "https://api.opensea.io/api/v2/chain/ethereum/contract/$CONTRACT/nfts/$TOKEN_ID" \ + -H "X-API-KEY: $OPENSEA_API_KEY" + +# Refresh metadata for a specific token +curl -X POST "https://api.opensea.io/api/v2/chain/ethereum/contract/$CONTRACT/nfts/$TOKEN_ID/refresh" \ + -H "X-API-KEY: $OPENSEA_API_KEY" + +# Get collection stats +curl "https://api.opensea.io/api/v2/collections/$COLLECTION_SLUG/stats" \ + -H "X-API-KEY: $OPENSEA_API_KEY" + +# List active orders for an NFT +curl "https://api.opensea.io/api/v2/orders/ethereum/seaport/listings?asset_contract_address=$CONTRACT&token_ids=$TOKEN_ID" \ + -H "X-API-KEY: $OPENSEA_API_KEY" +``` + +## ERC-6551 Registry + +| Chain | Address | Status | +|-------|---------|--------| +| All EVM | `0x000000006551c19487814612e58FE06813775758` | Active | + +## Deprecated Marketplaces + +| Protocol | Status | Migration | +|----------|--------|-----------| +| Reservoir | Shut down October 2025 | Use Seaport/OpenSea API directly | +| LooksRare v1 | Deprecated | LooksRare v2 or Seaport | +| Seaport 1.4 | Superseded | Seaport 1.6 | +| Seaport 1.5 | Superseded | Seaport 1.6 | + +## Verification + +```bash +# Verify Seaport 1.6 is deployed +cast code 0x0000000000000068F116A894984e2DB1123eB395 --rpc-url $RPC_URL + +# Verify ERC-6551 Registry +cast code 0x000000006551c19487814612e58FE06813775758 --rpc-url $RPC_URL + +# Query Seaport name +cast call 0x0000000000000068F116A894984e2DB1123eB395 "name()(string)" --rpc-url $RPC_URL +``` + +## Reference + +- [Seaport Protocol (ProjectOpenSea)](https://github.com/ProjectOpenSea/seaport) +- [Seaport 1.6 Deployment](https://github.com/ProjectOpenSea/seaport/releases) +- [OpenSea API Documentation](https://docs.opensea.io/reference/api-overview) +- [ERC-6551 Reference Deployments](https://github.com/erc6551/reference) diff --git a/skills/evm-nfts/resources/metadata-schema.md b/skills/evm-nfts/resources/metadata-schema.md new file mode 100644 index 0000000..c9489d0 --- /dev/null +++ b/skills/evm-nfts/resources/metadata-schema.md @@ -0,0 +1,132 @@ +# NFT Metadata Schema Reference + +JSON metadata schemas for ERC-721 tokens, ERC-1155 tokens, and collection-level metadata. + +## ERC-721 Token Metadata + +Returned by `tokenURI(tokenId)`. The URI resolves to this JSON structure. + +```json +{ + "name": "Token Name #1", + "description": "Description of this specific token.", + "image": "ipfs://QmImageHash/1.png", + "external_url": "https://example.com/token/1", + "animation_url": "ipfs://QmAnimationHash/1.mp4", + "background_color": "1a1a2e", + "attributes": [ + { + "trait_type": "Color", + "value": "Red" + }, + { + "trait_type": "Level", + "value": 5, + "display_type": "number" + }, + { + "trait_type": "Power", + "value": 85, + "max_value": 100 + }, + { + "display_type": "boost_percentage", + "trait_type": "Speed Boost", + "value": 15 + }, + { + "display_type": "date", + "trait_type": "Created", + "value": 1709251200 + } + ] +} +``` + +### Field Reference + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Human-readable name of the token | +| `description` | Yes | Text description (supports markdown on some platforms) | +| `image` | Yes | URL to the image (IPFS, Arweave, HTTPS, or data URI) | +| `external_url` | No | URL to view the token on your site | +| `animation_url` | No | URL to multimedia (video, audio, 3D model, HTML page) | +| `background_color` | No | Hex color without `#` prefix for display background | +| `attributes` | No | Array of trait objects for rarity/filtering | + +### Attribute Display Types + +| `display_type` | Rendering | Example | +|---------------|-----------|---------| +| _(omitted)_ | String trait | `"Color": "Red"` | +| `number` | Numeric bar | `"Level": 5` | +| `boost_number` | Numeric with `+` prefix | `"+15 Power"` | +| `boost_percentage` | Percentage with `+` prefix | `"+15% Speed"` | +| `date` | Unix timestamp as date | `"Created: Jan 1, 2025"` | + +## ERC-1155 URI Template + +The `uri(id)` function returns a URI template. The `{id}` placeholder is substituted client-side. + +``` +https://api.example.com/items/{id}.json +``` + +### Substitution Rules (per ERC-1155 spec) + +1. Replace `{id}` with the hex representation of the token ID +2. Zero-pad to 64 characters +3. Lowercase hex +4. No `0x` prefix + +| Token ID (decimal) | Substitution | +|--------------------|-------------| +| 0 | `0000000000000000000000000000000000000000000000000000000000000000` | +| 1 | `0000000000000000000000000000000000000000000000000000000000000001` | +| 255 | `00000000000000000000000000000000000000000000000000000000000000ff` | +| 10000 | `0000000000000000000000000000000000000000000000000000000000002710` | + +### TypeScript Helper + +```typescript +function substituteTokenId(template: string, tokenId: bigint): string { + const hex = tokenId.toString(16).padStart(64, "0"); + return template.replace("{id}", hex); +} +``` + +## Collection Metadata (contractURI) + +Returned by `contractURI()`. Provides collection-level information for marketplaces. + +```json +{ + "name": "Collection Name", + "description": "Description of the entire collection.", + "image": "ipfs://QmCollectionImageHash", + "banner_image": "ipfs://QmBannerImageHash", + "external_link": "https://example.com", + "collaborators": ["0xAddress1...", "0xAddress2..."], + "seller_fee_basis_points": 500, + "fee_recipient": "0xRoyaltyRecipientAddress..." +} +``` + +| Field | Description | +|-------|-------------| +| `name` | Collection name displayed on marketplace | +| `description` | Collection description | +| `image` | Collection avatar/logo image | +| `banner_image` | Wide banner image for collection page | +| `external_link` | Link to project website | +| `seller_fee_basis_points` | Royalty percentage in basis points (500 = 5%). Legacy OpenSea format. | +| `fee_recipient` | Address to receive royalties. Legacy OpenSea format. | + +Note: `seller_fee_basis_points` and `fee_recipient` in contractURI are the legacy OpenSea royalty format. ERC-2981 is the standard. Both should be set for maximum compatibility. + +## References + +- [OpenSea Metadata Standards](https://docs.opensea.io/docs/metadata-standards) +- [ERC-721 Metadata JSON Schema](https://eips.ethereum.org/EIPS/eip-721) +- [ERC-1155 Metadata URI](https://eips.ethereum.org/EIPS/eip-1155#metadata) From 32265e13caa8329d242a4a17bcaa386653a1eb12 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:31:42 -0800 Subject: [PATCH 04/20] feat: add evm-nfts starter template --- .../evm-nfts/templates/erc721-collection.sol | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 skills/evm-nfts/templates/erc721-collection.sol diff --git a/skills/evm-nfts/templates/erc721-collection.sol b/skills/evm-nfts/templates/erc721-collection.sol new file mode 100644 index 0000000..ed05eac --- /dev/null +++ b/skills/evm-nfts/templates/erc721-collection.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/** + * ERC-721 Collection Starter Template + * + * Production-ready ERC-721 with: + * - Paid mint with per-wallet limit and supply cap + * - ERC-2981 royalties (5%) + * - ReentrancyGuard on mint + * - ERC-4906 metadata update events + * - Owner-only withdrawal + * + * Usage: + * 1. Copy this file to your project's src/ directory + * 2. Install OpenZeppelin: forge install OpenZeppelin/openzeppelin-contracts@v5.6.1 + * 3. Update name, symbol, supply, price, and royalty settings + * 4. Add metadata URI logic (IPFS, Arweave, or onchain) + * + * Dependencies: @openzeppelin/contracts v5.6.1 + */ + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {IERC4906} from "@openzeppelin/contracts/interfaces/IERC4906.sol"; + +contract MyCollection is ERC721, ERC2981, IERC4906, Ownable, ReentrancyGuard { + using Strings for uint256; + + uint256 private _nextTokenId; + + // -- Configure these values -- + uint256 public constant MAX_SUPPLY = 10_000; + uint256 public constant MAX_PER_WALLET = 5; + uint256 public constant MINT_PRICE = 0.05 ether; + + string private _baseTokenURI; + bool public mintActive; + + mapping(address minter => uint256 count) public mintCount; + + error MaxSupplyReached(); + error ExceedsWalletLimit(); + error InsufficientPayment(); + error MintNotActive(); + error WithdrawFailed(); + + constructor( + address initialOwner, + address royaltyReceiver, + string memory baseURI + ) + ERC721("MyCollection", "MYC") + Ownable(initialOwner) + { + _baseTokenURI = baseURI; + // 5% royalty (500 basis points) + _setDefaultRoyalty(royaltyReceiver, 500); + } + + function mint(uint256 quantity) external payable nonReentrant { + if (!mintActive) revert MintNotActive(); + if (msg.value < MINT_PRICE * quantity) revert InsufficientPayment(); + if (mintCount[msg.sender] + quantity > MAX_PER_WALLET) revert ExceedsWalletLimit(); + + mintCount[msg.sender] += quantity; + + for (uint256 i = 0; i < quantity; i++) { + uint256 tokenId = _nextTokenId++; + if (tokenId >= MAX_SUPPLY) revert MaxSupplyReached(); + _safeMint(msg.sender, tokenId); + } + } + + // -- Owner functions -- + + function setMintActive(bool active) external onlyOwner { + mintActive = active; + } + + function setBaseURI(string calldata baseURI) external onlyOwner { + _baseTokenURI = baseURI; + emit BatchMetadataUpdate(0, _nextTokenId > 0 ? _nextTokenId - 1 : 0); + } + + function withdraw() external onlyOwner { + (bool sent, ) = payable(owner()).call{value: address(this).balance}(""); + if (!sent) revert WithdrawFailed(); + } + + // -- View functions -- + + function totalSupply() external view returns (uint256) { + return _nextTokenId; + } + + function contractURI() external pure returns (string memory) { + // Replace with your collection metadata URI + return ""; + } + + // -- Overrides -- + + function tokenURI(uint256 tokenId) public view override returns (string memory) { + require(_ownerOf(tokenId) != address(0), "Token does not exist"); + return string.concat(_baseTokenURI, tokenId.toString()); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721, ERC2981) + returns (bool) + { + return + interfaceId == bytes4(0x49064906) || // ERC-4906 + super.supportsInterface(interfaceId); + } +} From f891898c55e94c36c39bf1e1d5ef156351c485b9 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:58:17 -0800 Subject: [PATCH 05/20] feat: add privy skill --- skills/privy/SKILL.md | 597 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 skills/privy/SKILL.md diff --git a/skills/privy/SKILL.md b/skills/privy/SKILL.md new file mode 100644 index 0000000..be1116d --- /dev/null +++ b/skills/privy/SKILL.md @@ -0,0 +1,597 @@ +--- +name: privy +description: "Embedded wallet SDK for dApps with social login, email, and passkey auth. Covers React SDK, server-side JWT verification, wallet management, and smart wallet integration. Acquired by Stripe (2025)." +license: Apache-2.0 +metadata: + author: 0xinit + version: "1.0" + chain: multichain + category: Frontend +tags: + - embedded-wallet + - social-auth + - privy + - jwt + - react + - wallet-management + - passkeys + - waas + - authentication +--- + +# Privy + +Privy is an embedded wallet and authentication SDK that lets dApps onboard users with email, phone, social logins, passkeys, or existing wallets -- without requiring users to install a browser extension or manage seed phrases. The SDK creates non-custodial embedded wallets using 2-of-3 Shamir Secret Sharing (SSS) with TEE (Trusted Execution Environment) infrastructure. Privy was acquired by Stripe in June 2025, signaling deeper payment-rails integration ahead. + +## What You Probably Got Wrong + +- **HTTPS is required -- WebCrypto fails silently on HTTP** -- Privy's key sharding relies on the Web Crypto API, which only works in secure contexts. Loading your app over `http://` (except `localhost`) silently fails with no error message. The SDK initializes but wallet operations produce cryptic failures. Always deploy behind HTTPS. On local dev, `localhost` gets a browser exception, but `http://192.168.x.x` does not. The primary threat vector for SSS key sharding is browser malware on the device share. In the 2-of-3 SSS model, compromising ANY 2 shares reconstructs the full private key: device share (browser malware) + Privy share (Privy infrastructure breach) = full key compromise. The recovery share alone cannot reconstruct the key, but it reduces the attack surface to 2 parties instead of 3. + +- **Creating a Solana embedded wallet before EVM permanently blocks EVM wallet creation** -- Privy creates embedded wallets lazily after first login. If you call `createWallet({ type: 'solana' })` before the EVM wallet exists, the EVM wallet slot is permanently blocked for that user. Always create the EVM wallet first, or use `createOnLogin: 'all-users'` to auto-create both in the correct order. + +- **Farcaster login + `createOnLogin: 'users-without-wallets'` blocks embedded wallet creation** -- Farcaster accounts already have a custody wallet, so Privy treats them as "users with wallets" and skips embedded wallet creation. But the Farcaster custody wallet is not usable in-browser for signing transactions. Use `createOnLogin: 'all-users'` if you support Farcaster login, or manually call `createWallet()` after login. + +- **`verifyAuthToken` only works on ACCESS tokens, not identity tokens** -- Privy issues two token types: access tokens (short-lived, for API auth) and identity tokens (contain user profile data). Calling `verifyAuthToken(identityToken)` silently fails or throws a misleading error. For identity tokens, use `getUser({ idToken })` instead. Server-side verification requires your app SECRET (not app ID). + +- **v3 Solana peer dep migration is the #1 upgrade failure point** -- Privy v3 dropped `@solana/web3.js` in favor of `@solana/kit`. If you see peer dependency conflicts or runtime errors after upgrading, remove `@solana/web3.js` entirely and install `@solana/kit`. The API surface changed significantly -- `Connection` becomes `createSolanaRpc`, `PublicKey` becomes `address()`. + +- **Privy wallets are NOT custodial** -- The private key is split into 3 shares via Shamir Secret Sharing: (1) device share stored in the browser, (2) Privy share stored in TEE infrastructure, (3) recovery share set up by the user. Any 2 of 3 shares reconstruct the key. Privy alone cannot access user funds. + +- **Embedded wallets are created AFTER first login, not during** -- The `PrivyProvider` config `createOnLogin` controls this. The wallet does not exist during the login callback. Check for wallet existence after the login flow completes and the `useWallets()` hook updates. + +- **`useWallets()` returns ALL connected wallets, not just embedded** -- If a user connects MetaMask AND has a Privy embedded wallet, `useWallets()` returns both. Filter by `wallet.walletClientType === 'privy'` for embedded wallets, or `wallet.walletClientType === 'metamask'` for MetaMask. + +- **Privy is NOT RainbowKit** -- Privy is auth-first (email/social login that optionally creates a wallet). RainbowKit is wallet-first (user picks a wallet, then connects). They serve different user journeys. Privy targets web2 users who don't have wallets. RainbowKit targets web3 users who already do. + +- **Embedded wallets do NOT persist across browsers or devices** -- The device share is stored in browser local storage. A user logging in on a new device must complete recovery (or re-create a wallet) to access the same embedded wallet. Always prompt users to set up recovery during onboarding. + +- **`usePrivy()` returns `authenticated` but wallet might not be ready** -- Authentication and wallet initialization are separate states. After `authenticated === true`, the embedded wallet may still be loading. Check `wallet.ready` from `useWallets()` before attempting any signing or transaction operations. + +- **Privy does NOT include WalletConnect by default** -- To support external mobile wallets via WalletConnect, you must install `@privy-io/react-auth` with the WalletConnect connector and provide a WalletConnect project ID in the config. Without this, mobile users with external wallets cannot connect. + +## Critical Context + +> **Stripe acquisition:** June 2025. Privy is now a Stripe company. The SDK continues under the `@privy-io` npm scope. +> **Current version:** `@privy-io/react-auth` v3.14.1 (last verified March 2026) +> **Security model:** 2-of-3 Shamir Secret Sharing + TEE. Device share (browser), Privy share (TEE infra), recovery share (user-configured). +> **Supported chains:** All EVM chains + Solana. Chain configuration is per-app in the Privy dashboard. + +## Auth Methods + +Privy supports 15+ authentication methods. Configure in `PrivyProvider` via `loginMethods`. + +| Method | Config Key | Notes | +|--------|-----------|-------| +| Email (magic link) | `'email'` | Default. Sends OTP or magic link. | +| Phone (SMS) | `'sms'` | Sends OTP via SMS. | +| Google | `'google'` | OAuth 2.0. Requires Google client ID in dashboard. | +| Apple | `'apple'` | OAuth 2.0. Requires Apple Services ID. | +| Twitter/X | `'twitter'` | OAuth 1.0a. | +| Discord | `'discord'` | OAuth 2.0. | +| GitHub | `'github'` | OAuth 2.0. | +| LinkedIn | `'linkedin'` | OAuth 2.0. | +| Spotify | `'spotify'` | OAuth 2.0. | +| TikTok | `'tiktok'` | OAuth 2.0. | +| Farcaster | `'farcaster'` | Sign-in with Farcaster. Wallet NOT usable in-browser. | +| Passkey | `'passkey'` | WebAuthn. Device-bound. | +| Wallet (external) | `'wallet'` | MetaMask, Coinbase, WalletConnect, etc. | +| Telegram | `'telegram'` | Telegram Login Widget. | +| Custom auth | `'custom'` | Bring your own JWT. | + +```typescript +import { PrivyProvider } from "@privy-io/react-auth"; + + + {children} + +``` + +## React SDK + +### Installation + +```bash +npm install @privy-io/react-auth +``` + +### PrivyProvider Setup + +Wrap your app with `PrivyProvider` at the root. Must be inside a React tree (not in a Server Component for Next.js App Router). + +```tsx +"use client"; + +import { PrivyProvider } from "@privy-io/react-auth"; +import type { ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +``` + +### usePrivy Hook + +The primary hook for authentication state and actions. + +```typescript +import { usePrivy } from "@privy-io/react-auth"; + +function AuthComponent() { + const { + ready, // boolean -- SDK initialized + authenticated, // boolean -- user logged in + user, // PrivyUser | null -- user object with linked accounts + login, // () => void -- opens login modal + logout, // () => Promise -- logs out, clears session + linkEmail, // () => void -- link email to existing account + linkGoogle, // () => void -- link Google to existing account + linkWallet, // () => void -- link external wallet + getAccessToken, // () => Promise -- JWT for API calls + } = usePrivy(); + + if (!ready) return
Loading...
; + + if (!authenticated) { + return ; + } + + return ( +
+

User ID: {user?.id}

+ +
+ ); +} +``` + +### useWallets Hook + +Returns all connected wallets (embedded + external). Always filter by type. + +```typescript +import { useWallets } from "@privy-io/react-auth"; + +function WalletDisplay() { + const { ready, wallets } = useWallets(); + + if (!ready) return
Loading wallets...
; + + const embeddedWallet = wallets.find( + (w) => w.walletClientType === "privy" + ); + const externalWallets = wallets.filter( + (w) => w.walletClientType !== "privy" + ); + + return ( +
+ {embeddedWallet && ( +

Embedded: {embeddedWallet.address}

+ )} + {externalWallets.map((w) => ( +

+ {w.walletClientType}: {w.address} +

+ ))} +
+ ); +} +``` + +### useEmbeddedWallet Hook + +Direct access to the embedded wallet for creation and management. + +```typescript +import { + useEmbeddedWallet, + isNotCreated, + isConnected, +} from "@privy-io/react-auth"; + +function EmbeddedWalletManager() { + const wallet = useEmbeddedWallet(); + + if (isNotCreated(wallet)) { + return ( + + ); + } + + if (!isConnected(wallet)) { + return
Connecting wallet...
; + } + + return

Wallet: {wallet.address}

; +} +``` + +## Embedded Wallet Management + +### Sign a Message + +```typescript +import { useWallets } from "@privy-io/react-auth"; + +async function signMessage(wallets: ReturnType["wallets"]) { + const embeddedWallet = wallets.find( + (w) => w.walletClientType === "privy" + ); + if (!embeddedWallet) throw new Error("No embedded wallet found"); + + const provider = await embeddedWallet.getEthereumProvider(); + const signature = await provider.request({ + method: "personal_sign", + params: ["Hello from Privy!", embeddedWallet.address], + }); + + return signature; +} +``` + +### Send a Transaction + +```typescript +import { useWallets } from "@privy-io/react-auth"; +import { createWalletClient, custom, parseEther } from "viem"; +import { base } from "viem/chains"; + +async function sendTransaction( + wallets: ReturnType["wallets"] +) { + const embeddedWallet = wallets.find( + (w) => w.walletClientType === "privy" + ); + if (!embeddedWallet) throw new Error("No embedded wallet found"); + + await embeddedWallet.switchChain(base.id); + + const provider = await embeddedWallet.getEthereumProvider(); + const walletClient = createWalletClient({ + chain: base, + transport: custom(provider), + }); + + const [address] = await walletClient.getAddresses(); + const hash = await walletClient.sendTransaction({ + account: address, + to: "0xRecipient..." as `0x${string}`, + value: parseEther("0.001"), + }); + + return hash; +} +``` + +### Export Private Key + +Users can export their embedded wallet private key. This is a user-initiated action that requires Privy's export UI. + +```typescript +import { + useEmbeddedWallet, + isConnected, +} from "@privy-io/react-auth"; + +function ExportWallet() { + const wallet = useEmbeddedWallet(); + + if (!isConnected(wallet)) return null; + + return ( + + ); +} +``` + +## Server-Side Auth + +Privy issues JWTs for authenticated users. Use these to protect your API routes. + +### Access Token vs Identity Token + +| Token | Purpose | Verification Method | Contains | +|-------|---------|-------------------|----------| +| Access token | API authorization | `privy.verifyAuthToken(token)` | User ID, app ID, expiry | +| Identity token | User profile data | `privy.getUser({ idToken })` | Linked accounts, email, wallet addresses | + +### Express Middleware + +```typescript +import { PrivyClient } from "@privy-io/server-auth"; +import type { Request, Response, NextFunction } from "express"; + +const privy = new PrivyClient( + process.env.PRIVY_APP_ID!, + process.env.PRIVY_APP_SECRET! +); + +async function requireAuth( + req: Request, + res: Response, + next: NextFunction +) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Missing authorization header" }); + return; + } + + const token = authHeader.slice(7); + + try { + const claims = await privy.verifyAuthToken(token); + req.privyUserId = claims.userId; + next(); + } catch (error) { + res.status(401).json({ error: "Invalid or expired token" }); + return; + } +} +``` + +### Client-Side: Sending the Token + +```typescript +import { usePrivy } from "@privy-io/react-auth"; + +async function fetchProtectedData() { + const { getAccessToken } = usePrivy(); + const token = await getAccessToken(); + + const response = await fetch("/api/protected", { + headers: { Authorization: `Bearer ${token}` }, + }); + return response.json(); +} +``` + +### Getting User Profile (Identity Token) + +Use `getUser({ idToken })` for identity tokens (NOT `verifyAuthToken`). + +```typescript +const user = await privy.getUser({ idToken }); +// user.email?.address, user.wallet?.address, user.linkedAccounts +``` + +## Cross-Chain Support + +Privy embedded wallets support both EVM and Solana from the same authenticated user session. + +### EVM Chain Switching + +```typescript +const embeddedWallet = wallets.find( + (w) => w.walletClientType === "privy" +); + +// Switch to Arbitrum +await embeddedWallet.switchChain(42161); + +// Switch to Base +await embeddedWallet.switchChain(8453); +``` + +### Solana Embedded Wallet + +```typescript +import { useWallets } from "@privy-io/react-auth"; + +function SolanaWallet() { + const { wallets } = useWallets(); + + const solanaWallet = wallets.find( + (w) => w.walletClientType === "privy" && w.chainType === "solana" + ); + + if (!solanaWallet) return null; + + return

Solana address: {solanaWallet.address}

; +} +``` + +## Smart Wallet Integration + +### Privy + Safe (Account Abstraction) + +Privy embedded wallets can serve as the signer/owner for a Safe smart account, enabling gas sponsorship and batched transactions. + +```typescript +import { PrivyProvider } from "@privy-io/react-auth"; + + + {children} + +``` + +### Using Smart Wallets + +When smart wallets are enabled, Privy creates a Safe smart account with the embedded wallet as the owner. The smart wallet address is different from the embedded wallet address. + +```typescript +import { useWallets } from "@privy-io/react-auth"; + +function SmartWalletInfo() { + const { wallets } = useWallets(); + + const smartWallet = wallets.find( + (w) => w.walletClientType === "privy_smart_wallet" + ); + const embeddedWallet = wallets.find( + (w) => w.walletClientType === "privy" + ); + + return ( +
+ {embeddedWallet && ( +

Signer (EOA): {embeddedWallet.address}

+ )} + {smartWallet && ( +

Smart Wallet (Safe): {smartWallet.address}

+ )} +
+ ); +} +``` + +### Sponsored Transactions with Smart Wallets + +Smart wallets enable gas sponsorship through Privy's paymaster. Users pay zero gas. + +```typescript +async function sendSponsoredTx( + smartWallet: ConnectedWallet +) { + const provider = await smartWallet.getEthereumProvider(); + const walletClient = createWalletClient({ + chain: base, + transport: custom(provider), + }); + + const [account] = await walletClient.getAddresses(); + + // Gas is sponsored by the paymaster -- user pays nothing + const hash = await walletClient.sendTransaction({ + account, + to: "0xRecipient..." as `0x${string}`, + value: parseEther("0.001"), + }); + + return hash; +} +``` + +### Privy + ZeroDev + +For advanced account abstraction (session keys, custom validators), use ZeroDev's Kernel with Privy as the signer. + +```typescript +import { createKernelAccount } from "@zerodev/sdk"; +import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator"; +import { providerToSmartAccountSigner } from "permissionless"; + +async function createZeroDevAccount( + embeddedWallet: ConnectedWallet +) { + const provider = await embeddedWallet.getEthereumProvider(); + const signer = await providerToSmartAccountSigner(provider); + + const ecdsaValidator = await signerToEcdsaValidator(publicClient, { + signer, + entryPoint: entryPoint07Address, + }); + + const kernelAccount = await createKernelAccount(publicClient, { + plugins: { sudo: ecdsaValidator }, + entryPoint: entryPoint07Address, + }); + + return kernelAccount; +} +``` + +## Custom UI / Headless Mode + +Privy provides a default login modal, but you can build fully custom UI using headless hooks. + +Each auth method has a headless hook: `useLoginWithEmail` (sendCode/loginWithCode flow), `useLoginWithOAuth` (initOAuth with provider), `useLoginWithPasskey`, `useLoginWithWallet`, `useLoginWithFarcaster`, and `useLoginWithCustomAuth`. Each hook exposes a `state` object for tracking the multi-step flow. + +```typescript +import { useLoginWithEmail } from "@privy-io/react-auth"; + +function CustomEmailLogin() { + const { sendCode, loginWithCode, state } = useLoginWithEmail(); + + if (state.status === "awaiting-code-input") { + return ; + } + + return ; +} +``` + +```typescript +import { useLoginWithOAuth } from "@privy-io/react-auth"; + +function GoogleLogin() { + const { initOAuth } = useLoginWithOAuth(); + return ( + + ); +} +``` + +## Alternatives Comparison + +| Feature | Privy | Dynamic | Web3Auth | Magic | +|---------|-------|---------|----------|-------| +| Auth-first (social login) | Yes | Yes | Yes | Yes | +| Embedded wallets | Yes (SSS + TEE) | Yes (MPC) | Yes (MPC/TSS) | Yes (delegated key) | +| Smart wallet (AA) built-in | Yes (Safe) | Yes | No (BYO) | No | +| Solana support | Yes | Yes | Yes | Limited | +| Passkey support | Yes | Yes | Yes | No | +| Headless mode | Yes | Yes | Yes | Yes | +| Farcaster login | Yes | No | No | No | +| WalletConnect built-in | Opt-in | Yes | Opt-in | No | +| Stripe integration | Native (acquired) | No | No | No | +| Pricing | Free tier + usage | Free tier + usage | Free tier + usage | Free tier + usage | + +## Related Skills + +- **frontend-ux** -- dApp UX patterns, transaction lifecycle, error handling. Privy handles auth; frontend-ux handles everything after. +- **wagmi** -- React hooks for Ethereum. Privy's embedded wallet provider is compatible with wagmi's `custom` transport. +- **safe** -- Safe smart accounts. Privy's smart wallet mode uses Safe under the hood. See safe skill for multisig patterns. +- **account-abstraction** -- ERC-4337 and EIP-7702 deep dive. Privy's smart wallets build on this infrastructure. + +## References + +- Privy Documentation: https://docs.privy.io +- Privy React SDK: https://www.npmjs.com/package/@privy-io/react-auth +- Privy Server Auth: https://www.npmjs.com/package/@privy-io/server-auth +- Privy Dashboard: https://dashboard.privy.io +- Privy GitHub: https://github.com/privy-io +- Privy Security Model: https://docs.privy.io/guide/security +- Stripe Acquisition Announcement: https://stripe.com/blog/privy (June 2025) +- Shamir Secret Sharing: https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing From cf8b7107d6cc446b22d502153e385bbab5654e05 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:34:05 -0800 Subject: [PATCH 06/20] feat: add privy examples --- .../examples/embedded-wallet-tx/README.md | 238 ++++++++++++++++ skills/privy/examples/server-auth/README.md | 201 ++++++++++++++ skills/privy/examples/smart-wallet/README.md | 259 ++++++++++++++++++ skills/privy/examples/social-login/README.md | 161 +++++++++++ 4 files changed, 859 insertions(+) create mode 100644 skills/privy/examples/embedded-wallet-tx/README.md create mode 100644 skills/privy/examples/server-auth/README.md create mode 100644 skills/privy/examples/smart-wallet/README.md create mode 100644 skills/privy/examples/social-login/README.md diff --git a/skills/privy/examples/embedded-wallet-tx/README.md b/skills/privy/examples/embedded-wallet-tx/README.md new file mode 100644 index 0000000..5019148 --- /dev/null +++ b/skills/privy/examples/embedded-wallet-tx/README.md @@ -0,0 +1,238 @@ +# Send ETH from Embedded Wallet + +Working TypeScript/React example for sending an ETH transaction from a Privy embedded wallet using viem. + +## Dependencies + +```bash +npm install @privy-io/react-auth viem +``` + +## Transaction Component + +```tsx +// components/SendTransaction.tsx +"use client"; + +import { useState } from "react"; +import { usePrivy, useWallets } from "@privy-io/react-auth"; +import { + createWalletClient, + createPublicClient, + custom, + http, + parseEther, + formatEther, + type Hash, +} from "viem"; +import { base } from "viem/chains"; + +type TxState = + | "idle" + | "switching-chain" + | "awaiting-signature" + | "pending" + | "confirmed" + | "failed"; + +export function SendTransaction() { + const { authenticated } = usePrivy(); + const { ready, wallets } = useWallets(); + const [txState, setTxState] = useState("idle"); + const [txHash, setTxHash] = useState(null); + const [error, setError] = useState(null); + const [recipient, setRecipient] = useState(""); + const [amount, setAmount] = useState(""); + + const embeddedWallet = wallets.find( + (w) => w.walletClientType === "privy" + ); + + if (!authenticated || !ready) { + return

Please log in first.

; + } + + if (!embeddedWallet) { + return

No embedded wallet found. Wait for wallet creation.

; + } + + async function handleSend() { + if (!embeddedWallet) return; + if (!recipient || !amount) return; + + setTxState("switching-chain"); + setError(null); + setTxHash(null); + + try { + await embeddedWallet.switchChain(base.id); + + setTxState("awaiting-signature"); + + const provider = await embeddedWallet.getEthereumProvider(); + const walletClient = createWalletClient({ + chain: base, + transport: custom(provider), + }); + + const publicClient = createPublicClient({ + chain: base, + transport: http(), + }); + + const [account] = await walletClient.getAddresses(); + + const hash = await walletClient.sendTransaction({ + account, + to: recipient as `0x${string}`, + value: parseEther(amount), + }); + + setTxHash(hash); + setTxState("pending"); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash, + }); + + if (receipt.status === "success") { + setTxState("confirmed"); + } else { + setTxState("failed"); + setError("Transaction reverted on-chain."); + } + } catch (err) { + setTxState("failed"); + const message = err instanceof Error ? err.message : "Unknown error"; + + // User rejection -- silently reset + if ( + message.includes("User rejected") || + message.includes("user denied") + ) { + setTxState("idle"); + return; + } + + setError(message); + } + } + + function reset() { + setTxState("idle"); + setTxHash(null); + setError(null); + } + + const explorerUrl = txHash + ? `https://basescan.org/tx/${txHash}` + : null; + + const buttonLabels: Record = { + idle: "Send ETH", + "switching-chain": "Switching to Base...", + "awaiting-signature": "Confirm in wallet...", + pending: "Waiting for confirmation...", + confirmed: "Transaction confirmed", + failed: "Transaction failed", + }; + + return ( +
+

Send ETH on Base

+

From: {embeddedWallet.address}

+ +
+ + setRecipient(e.target.value)} + placeholder="0x..." + disabled={txState !== "idle"} + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="0.001" + type="text" + inputMode="decimal" + disabled={txState !== "idle"} + /> +
+ + + + {txState === "pending" && explorerUrl && ( +

+ Submitted.{" "} + + View on BaseScan + +

+ )} + + {txState === "confirmed" && explorerUrl && ( +
+

+ Confirmed.{" "} + + View on BaseScan + +

+ +
+ )} + + {txState === "failed" && error && ( +
+

{error}

+ +
+ )} +
+ ); +} +``` + +## Usage + +```tsx +// app/send/page.tsx +"use client"; + +import { SendTransaction } from "@/components/SendTransaction"; + +export default function SendPage() { + return ( +
+

Embedded Wallet Transaction

+ +
+ ); +} +``` + +## Notes + +- The component follows the four-state transaction lifecycle: idle, awaiting-signature, pending, confirmed/failed. See the `frontend-ux` skill for the full pattern. +- Chain switching is done before the transaction. The embedded wallet supports any EVM chain configured in `PrivyProvider.supportedChains`. +- User rejection (closing the signing popup) silently resets to idle -- no error toast. +- viem's `createWalletClient` with `custom(provider)` wraps Privy's EIP-1193 provider. This gives you full viem API (contract writes, typed data signing, etc.). +- Always use `parseEther` for ETH amounts and `bigint` for token amounts. Never use JavaScript `number`. +- The `publicClient` is used separately for `waitForTransactionReceipt` because the wallet client does not include read methods. diff --git a/skills/privy/examples/server-auth/README.md b/skills/privy/examples/server-auth/README.md new file mode 100644 index 0000000..14590c5 --- /dev/null +++ b/skills/privy/examples/server-auth/README.md @@ -0,0 +1,201 @@ +# Server-Side JWT Verification with Privy + +Working TypeScript example for an Express server that verifies Privy access tokens to protect API routes. + +## Dependencies + +```bash +npm install @privy-io/server-auth express +npm install -D @types/express typescript +``` + +## Environment Variables + +```bash +# .env +PRIVY_APP_ID=your-privy-app-id # From dashboard.privy.io +PRIVY_APP_SECRET=your-privy-app-secret # From dashboard.privy.io > Settings > API Keys +PORT=3001 +``` + +## Privy Client Setup + +```typescript +// lib/privy.ts +import { PrivyClient } from "@privy-io/server-auth"; + +export const privy = new PrivyClient( + process.env.PRIVY_APP_ID!, + process.env.PRIVY_APP_SECRET! +); +``` + +## Auth Middleware + +```typescript +// middleware/auth.ts +import type { Request, Response, NextFunction } from "express"; +import { privy } from "../lib/privy"; + +declare global { + namespace Express { + interface Request { + privyUserId?: string; + } + } +} + +export async function requireAuth( + req: Request, + res: Response, + next: NextFunction +) { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Missing authorization header" }); + return; + } + + const token = authHeader.slice(7); + + try { + // verifyAuthToken works ONLY with access tokens (from getAccessToken) + // For identity tokens, use privy.getUser({ idToken }) instead + const claims = await privy.verifyAuthToken(token); + req.privyUserId = claims.userId; + next(); + } catch (error) { + res.status(401).json({ error: "Invalid or expired access token" }); + return; + } +} +``` + +## User Profile Endpoint (Identity Token) + +```typescript +// routes/user.ts +import { Router } from "express"; +import { privy } from "../lib/privy"; + +const router = Router(); + +router.get("/profile", async (req: Request, res: Response) => { + const idToken = req.headers["x-id-token"] as string | undefined; + if (!idToken) { + res.status(400).json({ error: "Missing x-id-token header" }); + return; + } + + try { + const user = await privy.getUser({ idToken }); + res.json({ + id: user.id, + email: user.email?.address ?? null, + wallet: user.wallet?.address ?? null, + linkedAccounts: user.linkedAccounts.map((a) => ({ + type: a.type, + ...(a.type === "email" && { address: a.address }), + ...(a.type === "wallet" && { address: a.address }), + ...(a.type === "google_oauth" && { email: a.email }), + })), + createdAt: user.createdAt, + }); + } catch (error) { + res.status(401).json({ error: "Invalid identity token" }); + return; + } +}); + +export default router; +``` + +## Protected API Route + +```typescript +// routes/data.ts +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; + +const router = Router(); + +router.get("/data", requireAuth, (req: Request, res: Response) => { + res.json({ + message: "Authenticated request", + userId: req.privyUserId, + timestamp: Date.now(), + }); +}); + +router.post("/action", requireAuth, (req: Request, res: Response) => { + const { action, params } = req.body; + + res.json({ + success: true, + userId: req.privyUserId, + action, + executedAt: Date.now(), + }); +}); + +export default router; +``` + +## Server Entry + +```typescript +// server.ts +import express from "express"; +import cors from "cors"; +import dataRouter from "./routes/data"; +import userRouter from "./routes/user"; + +const app = express(); +const port = Number(process.env.PORT) || 3001; + +app.use(cors({ origin: process.env.FRONTEND_URL })); +app.use(express.json()); + +app.use("/api", dataRouter); +app.use("/api", userRouter); + +app.listen(port, () => { + console.log(`Server running on port ${port}`); +}); +``` + +## Client-Side: Sending Tokens + +```typescript +// React component using Privy hooks +import { usePrivy } from "@privy-io/react-auth"; + +async function fetchProtectedData(): Promise { + const { getAccessToken } = usePrivy(); + const accessToken = await getAccessToken(); + + if (!accessToken) { + throw new Error("Not authenticated"); + } + + const response = await fetch("http://localhost:3001/api/data", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return response.json(); +} +``` + +## Notes + +- **Access tokens vs identity tokens:** `verifyAuthToken` validates access tokens only. Access tokens contain the user ID and app ID but no profile data. For user profile data (email, linked accounts, wallet addresses), use `getUser({ idToken })` with an identity token. +- **App secret is required:** The `PrivyClient` constructor requires both app ID and app secret. The app secret is found in the Privy dashboard under Settings > API Keys. Never expose the app secret to the client. +- **Token refresh:** Access tokens are short-lived. The client's `getAccessToken()` auto-refreshes expired tokens. Always call `getAccessToken()` before each API request rather than caching the token. +- **CORS:** Configure `cors({ origin })` to your frontend's exact domain. Do not use `origin: '*'` in production. +- **The claims object** returned by `verifyAuthToken` contains: `userId` (Privy user ID), `appId` (your app ID), `issuer`, `issuedAt`, and `expiration`. diff --git a/skills/privy/examples/smart-wallet/README.md b/skills/privy/examples/smart-wallet/README.md new file mode 100644 index 0000000..720c699 --- /dev/null +++ b/skills/privy/examples/smart-wallet/README.md @@ -0,0 +1,259 @@ +# Privy + Safe Smart Wallet + +Working TypeScript/React example for using Privy's built-in smart wallet integration (powered by Safe) with gas sponsorship. + +## Dependencies + +```bash +npm install @privy-io/react-auth viem +``` + +## Provider Setup with Smart Wallets + +```tsx +// app/providers.tsx +"use client"; + +import { PrivyProvider } from "@privy-io/react-auth"; +import { base } from "viem/chains"; +import type { ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +``` + +## Smart Wallet Component + +```tsx +// components/SmartWallet.tsx +"use client"; + +import { useState } from "react"; +import { usePrivy, useWallets } from "@privy-io/react-auth"; +import { + createWalletClient, + createPublicClient, + custom, + http, + parseEther, + formatEther, + type Hash, +} from "viem"; +import { base } from "viem/chains"; + +export function SmartWallet() { + const { authenticated } = usePrivy(); + const { ready, wallets } = useWallets(); + const [txHash, setTxHash] = useState(null); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + + // Smart wallet has walletClientType === "privy_smart_wallet" + const smartWallet = wallets.find( + (w) => w.walletClientType === "privy_smart_wallet" + ); + // The embedded EOA wallet is the owner/signer + const embeddedWallet = wallets.find( + (w) => w.walletClientType === "privy" + ); + + if (!authenticated || !ready) { + return

Please log in first.

; + } + + if (!smartWallet) { + return ( +
+

Smart wallet not available.

+ {embeddedWallet && ( +

EOA signer ready: {embeddedWallet.address}

+ )} +

+ Ensure smartWallets.enabled: true is set in + PrivyProvider config. +

+
+ ); + } + + async function handleSendSponsored() { + if (!smartWallet) return; + + setSending(true); + setError(null); + setTxHash(null); + + try { + const provider = await smartWallet.getEthereumProvider(); + const walletClient = createWalletClient({ + chain: base, + transport: custom(provider), + }); + + const publicClient = createPublicClient({ + chain: base, + transport: http(), + }); + + const [account] = await walletClient.getAddresses(); + + // Gas is sponsored through Privy's paymaster infrastructure + // The user pays zero gas for this transaction + const hash = await walletClient.sendTransaction({ + account, + to: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" as `0x${string}`, + value: parseEther("0.0001"), + }); + + setTxHash(hash); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash, + }); + + if (receipt.status !== "success") { + setError("Transaction reverted on-chain."); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + if ( + message.includes("User rejected") || + message.includes("user denied") + ) { + setSending(false); + return; + } + setError(message); + } finally { + setSending(false); + } + } + + const explorerUrl = txHash + ? `https://basescan.org/tx/${txHash}` + : null; + + return ( +
+

Smart Wallet (Safe)

+ +
+
Smart Wallet Address
+
{smartWallet.address}
+ + {embeddedWallet && ( + <> +
Signer (EOA)
+
{embeddedWallet.address}
+ + )} +
+ +

+ The smart wallet address is a Safe contract owned by your embedded + wallet. Transactions are sponsored -- you pay zero gas. +

+ + + + {txHash && explorerUrl && ( +

+ Transaction: {" "} + + View on BaseScan + +

+ )} + + {error && ( +
+

{error}

+
+ )} +
+ ); +} +``` + +## Batch Transactions with Smart Wallet + +Smart wallets support batched calls in a single transaction via the Safe's `multiSend` capability. + +```typescript +import { encodeFunctionData } from "viem"; + +const erc20Abi = [ + { + name: "transfer", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +] as const; + +async function batchTransfer(smartWallet: ConnectedWallet) { + const provider = await smartWallet.getEthereumProvider(); + + // Privy smart wallets support eth_sendTransaction with batch encoding + // through the Safe's built-in multiSend + const walletClient = createWalletClient({ + chain: base, + transport: custom(provider), + }); + + const [account] = await walletClient.getAddresses(); + const TOKEN = "0xTokenAddress..." as `0x${string}`; + + const hash = await walletClient.sendTransaction({ + account, + to: TOKEN, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [ + "0xRecipient1..." as `0x${string}`, + 1000000n, // 1 USDC (6 decimals) + ], + }), + }); + + return hash; +} +``` + +## Notes + +- **Smart wallet address differs from embedded wallet address.** The embedded wallet (EOA) is the owner/signer of the Safe smart wallet. Send funds to the smart wallet address, not the EOA. +- **Gas sponsorship** is configured in the Privy dashboard under your app's gas policy. On testnets, Privy sponsors gas by default. On mainnet, you configure sponsorship rules (per-user limits, allowlisted contracts, etc.). +- **Smart wallets are deployed lazily.** The Safe contract is deployed on the user's first transaction, not at wallet creation time. The address is deterministic (CREATE2) so it can receive funds before deployment. +- **The Safe smart wallet is a 1-of-1 multisig** with the Privy embedded wallet as the sole owner. For multi-owner Safe setups, use the Safe SDK directly with the Privy embedded wallet as one of the signers (see the `safe` skill). +- **Chain support:** Smart wallets work on any EVM chain in your `supportedChains` config. The same smart wallet address is valid across all chains (counterfactual deployment). diff --git a/skills/privy/examples/social-login/README.md b/skills/privy/examples/social-login/README.md new file mode 100644 index 0000000..4ada631 --- /dev/null +++ b/skills/privy/examples/social-login/README.md @@ -0,0 +1,161 @@ +# Social Login with Privy + +Working TypeScript/React example for a Next.js app with Google and email login, displaying authenticated user state and embedded wallet address. + +## Dependencies + +```bash +npm install @privy-io/react-auth next react react-dom +``` + +## Environment Variables + +```bash +# .env.local +NEXT_PUBLIC_PRIVY_APP_ID=your-privy-app-id # From dashboard.privy.io +``` + +## Provider Setup + +```tsx +// app/providers.tsx +"use client"; + +import { PrivyProvider } from "@privy-io/react-auth"; +import { mainnet, base } from "viem/chains"; +import type { ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +``` + +## Layout + +```tsx +// app/layout.tsx +import { Providers } from "./providers"; +import type { ReactNode } from "react"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +## Login Page + +```tsx +// app/page.tsx +"use client"; + +import { usePrivy, useWallets } from "@privy-io/react-auth"; + +export default function Home() { + const { ready, authenticated, user, login, logout } = usePrivy(); + const { ready: walletsReady, wallets } = useWallets(); + + if (!ready) { + return
Loading Privy...
; + } + + if (!authenticated) { + return ( +
+

Welcome

+

Sign in with your email or Google account.

+ +
+ ); + } + + const embeddedWallet = wallets.find( + (w) => w.walletClientType === "privy" + ); + + const displayEmail = user?.email?.address; + const displayGoogle = user?.google?.email; + + return ( +
+

Dashboard

+ +
+

Account

+
+
User ID
+
{user?.id}
+ + {displayEmail && ( + <> +
Email
+
{displayEmail}
+ + )} + + {displayGoogle && ( + <> +
Google
+
{displayGoogle}
+ + )} + +
Linked accounts
+
{user?.linkedAccounts.length ?? 0}
+
+
+ +
+

Embedded Wallet

+ {!walletsReady ? ( +

Loading wallet...

+ ) : embeddedWallet ? ( +
+
Address
+
{embeddedWallet.address}
+
Chain ID
+
{embeddedWallet.chainId}
+
+ ) : ( +

No embedded wallet found.

+ )} +
+ + +
+ ); +} +``` + +## Notes + +- The `PrivyProvider` must be in a Client Component (`"use client"`) for Next.js App Router. +- `createOnLogin: 'all-users'` creates an embedded wallet for every user, including those who logged in with an external wallet. Use `'users-without-wallets'` to skip wallet creation for users connecting MetaMask, etc. +- The login modal appearance (theme, accent color, logo) is configured in the `appearance` object. You can also configure this in the Privy dashboard. +- Always check `ready` from both `usePrivy()` and `useWallets()` before rendering wallet-dependent UI. +- HTTPS is required in production. `localhost` works for local development. From 0a4e179257295ae300cf0318d295fdf95b3cdca5 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:52:39 -0800 Subject: [PATCH 07/20] feat: add privy docs and resources --- skills/privy/docs/troubleshooting.md | 140 ++++++++++++++++++++++++ skills/privy/resources/auth-methods.md | 95 ++++++++++++++++ skills/privy/resources/error-codes.md | 52 +++++++++ skills/privy/resources/sdk-reference.md | 100 +++++++++++++++++ 4 files changed, 387 insertions(+) create mode 100644 skills/privy/docs/troubleshooting.md create mode 100644 skills/privy/resources/auth-methods.md create mode 100644 skills/privy/resources/error-codes.md create mode 100644 skills/privy/resources/sdk-reference.md diff --git a/skills/privy/docs/troubleshooting.md b/skills/privy/docs/troubleshooting.md new file mode 100644 index 0000000..57a22ea --- /dev/null +++ b/skills/privy/docs/troubleshooting.md @@ -0,0 +1,140 @@ +# Privy Troubleshooting + +Common issues when integrating Privy's React SDK, embedded wallets, and server-side auth. + +## HTTPS / Secure Context Errors + +**Symptom:** SDK initializes but wallet creation or signing silently fails. No error in console. + +**Cause:** Web Crypto API requires a secure context. `http://` origins (except `localhost`) are not secure contexts. + +**Fix:** +- Local dev: use `localhost` (not `127.0.0.1` or `192.168.x.x` over HTTP) +- Staging/production: always HTTPS +- If using a tunnel (ngrok, cloudflared), ensure the tunnel URL is HTTPS + +```bash +# ngrok provides HTTPS automatically +ngrok http 3000 +``` + +## Embedded Wallet Not Created After Login + +**Symptom:** User logs in successfully but `useWallets()` returns empty array. `useEmbeddedWallet()` shows `not-created`. + +**Causes and fixes:** + +1. **`createOnLogin` not set:** Default is `'off'`. Set to `'all-users'` or `'users-without-wallets'`. +2. **Farcaster login with `'users-without-wallets'`:** Farcaster accounts have a custody wallet, so Privy skips embedded wallet creation. Use `'all-users'` instead. +3. **Checking too early:** Wallet creation is async. Wait for `useWallets()` `ready === true`. + +```typescript +const { ready, wallets } = useWallets(); +// Do NOT check wallets until ready === true +if (!ready) return
Loading...
; +``` + +## Solana Wallet Created Before EVM + +**Symptom:** EVM embedded wallet cannot be created. `createWallet()` for EVM throws error. + +**Cause:** Creating a Solana embedded wallet first permanently blocks EVM wallet creation for that user. + +**Fix:** No fix for affected users -- they must create a new account. Prevent this by: +- Using `createOnLogin: 'all-users'` (creates both in correct order) +- If creating manually, always create EVM first: `createWallet({ type: 'ethereum' })` before `createWallet({ type: 'solana' })` + +## JWT Verification Failures + +**Symptom:** `verifyAuthToken` throws "Invalid token" or returns unexpected results. + +**Common causes:** + +1. **Using identity token instead of access token:** `verifyAuthToken` only works with access tokens. For identity tokens, use `getUser({ idToken })`. + +```typescript +// Access token (from getAccessToken on client) +const claims = await privy.verifyAuthToken(accessToken); + +// Identity token (from getIdToken on client) +const user = await privy.getUser({ idToken: identityToken }); +``` + +2. **Using app ID instead of app secret:** Server-side `PrivyClient` requires both app ID AND app secret. + +```typescript +const privy = new PrivyClient( + process.env.PRIVY_APP_ID!, // "clx..." format + process.env.PRIVY_APP_SECRET! // "secret-..." format +); +``` + +3. **Token expired:** Access tokens have short TTLs. Call `getAccessToken()` on the client before each API request -- it auto-refreshes. + +## v3 Solana Peer Dependency Errors + +**Symptom:** `npm install` fails with peer dependency conflicts mentioning `@solana/web3.js`. + +**Cause:** Privy v3 migrated from `@solana/web3.js` to `@solana/kit`. + +**Fix:** + +```bash +npm uninstall @solana/web3.js +npm install @solana/kit +``` + +Update imports: + +```typescript +// Before (v2) +import { Connection, PublicKey } from "@solana/web3.js"; + +// After (v3) +import { createSolanaRpc, address } from "@solana/kit"; +``` + +## Wallet Not Ready After Authentication + +**Symptom:** `authenticated === true` but signing transactions throws "wallet not ready". + +**Cause:** Authentication and wallet initialization are separate async operations. + +**Fix:** Check both `authenticated` and wallet `ready` state: + +```typescript +const { authenticated } = usePrivy(); +const { ready: walletsReady, wallets } = useWallets(); + +const canTransact = authenticated && walletsReady && wallets.length > 0; +``` + +## WalletConnect Not Working + +**Symptom:** External mobile wallets cannot connect. No WalletConnect QR code shown. + +**Cause:** Privy does not include WalletConnect by default. + +**Fix:** Add WalletConnect project ID to your Privy config: + +```typescript + +``` + +## Cross-Browser Wallet Access + +**Symptom:** User logs in on a different browser or device and embedded wallet is missing. + +**Cause:** The device share (1 of 3 SSS shares) is stored in browser local storage. A new browser has no device share. + +**Fix:** Users must set up recovery (password or cloud backup) during initial onboarding. Prompt recovery setup after first wallet creation. Without recovery, the wallet is inaccessible from other devices. diff --git a/skills/privy/resources/auth-methods.md b/skills/privy/resources/auth-methods.md new file mode 100644 index 0000000..982c4c0 --- /dev/null +++ b/skills/privy/resources/auth-methods.md @@ -0,0 +1,95 @@ +# Privy Auth Methods Reference + +> **Last verified:** March 2026 (`@privy-io/react-auth` v3.14.1) + +All authentication methods supported by Privy, with configuration examples. + +## Config Overview + +Auth methods are configured in the `loginMethods` array of `PrivyProvider`. Social OAuth providers require additional setup in the Privy dashboard (client IDs, redirect URIs). + +```typescript + +``` + +## Method Reference + +| Method | Config Key | Type | Dashboard Setup | Notes | +|--------|-----------|------|-----------------|-------| +| Email | `'email'` | OTP / Magic Link | None | Default method. Sends 6-digit OTP. | +| Phone (SMS) | `'sms'` | OTP | None | SMS-based OTP. International support. | +| Google | `'google'` | OAuth 2.0 | Google client ID | Most common social login. | +| Apple | `'apple'` | OAuth 2.0 | Apple Services ID + key | Requires Apple Developer account. | +| Twitter/X | `'twitter'` | OAuth 1.0a | Twitter API keys | Legacy OAuth flow. | +| Discord | `'discord'` | OAuth 2.0 | Discord application | Popular for gaming/community dApps. | +| GitHub | `'github'` | OAuth 2.0 | GitHub OAuth app | Developer-focused dApps. | +| LinkedIn | `'linkedin'` | OAuth 2.0 | LinkedIn app | Professional identity. | +| Spotify | `'spotify'` | OAuth 2.0 | Spotify app | Music/entertainment dApps. | +| TikTok | `'tiktok'` | OAuth 2.0 | TikTok developer app | Social content dApps. | +| Farcaster | `'farcaster'` | SIWF | None | Sign-in with Farcaster. Custody wallet NOT usable in-browser. | +| Passkey | `'passkey'` | WebAuthn | None | Device-bound biometric auth. | +| Wallet | `'wallet'` | EIP-1193 | Optional WC project ID | External wallets (MetaMask, Coinbase, etc.). | +| Telegram | `'telegram'` | Telegram Login Widget | Telegram bot token | Mini App and bot integrations. | +| Custom | `'custom'` | JWT | JWKS endpoint config | Bring your own auth provider. | + +## Headless Login Hooks + +Each auth method has a corresponding headless hook for custom UI. + +| Method | Hook | Key Methods | +|--------|------|-------------| +| Email | `useLoginWithEmail()` | `sendCode({ email })`, `loginWithCode({ code })` | +| Phone | `useLoginWithSms()` | `sendCode({ phone })`, `loginWithCode({ code })` | +| OAuth (all) | `useLoginWithOAuth()` | `initOAuth({ provider: 'google' })` | +| Passkey | `useLoginWithPasskey()` | `loginWithPasskey()` | +| Wallet | `useLoginWithWallet()` | `loginWithWallet()` | +| Farcaster | `useLoginWithFarcaster()` | `loginWithFarcaster()` | +| Custom JWT | `useLoginWithCustomAuth()` | `loginWithCustomAuth({ token })` | + +## Custom Auth (Bring Your Own JWT) + +For apps with existing auth systems, Privy accepts your JWT and creates a Privy user linked to your identity. + +```typescript + { + const response = await fetch("/api/auth/token"); + const { token } = await response.json(); + return token; + }, + }, + }} +> +``` + +## Account Linking + +Users can link multiple auth methods to a single Privy account after initial login. + +```typescript +const { + linkEmail, + linkGoogle, + linkWallet, + linkPasskey, + linkPhone, + linkDiscord, + linkTwitter, + linkGithub, +} = usePrivy(); +``` + +## OAuth Redirect Configuration + +Social OAuth methods redirect to `https://your-domain.com/` after authentication. Configure allowed redirect URIs in the Privy dashboard under your app settings. For local development, add `http://localhost:3000` as an allowed origin. diff --git a/skills/privy/resources/error-codes.md b/skills/privy/resources/error-codes.md new file mode 100644 index 0000000..faad94f --- /dev/null +++ b/skills/privy/resources/error-codes.md @@ -0,0 +1,52 @@ +# Privy Error Codes Reference + +> **Last verified:** March 2026 (`@privy-io/react-auth` v3.14.1) + +Common errors from Privy SDK and server-auth, with causes and fixes. + +## Client-Side Errors (React SDK) + +| Error | Cause | Fix | +|-------|-------|-----| +| `MISSING_OR_INVALID_PRIVY_APP_ID` | `appId` prop on `PrivyProvider` is undefined or malformed | Check `NEXT_PUBLIC_PRIVY_APP_ID` env var is set and starts with `cl` | +| `NOT_READY` | Calling hooks before SDK initialization | Check `ready === true` from `usePrivy()` before any operations | +| `USER_NOT_AUTHENTICATED` | Calling wallet/account operations before login | Check `authenticated === true` before calling wallet methods | +| `EMBEDDED_WALLET_NOT_FOUND` | Accessing embedded wallet before creation | Set `createOnLogin: 'all-users'` or call `createWallet()` manually | +| `EMBEDDED_WALLET_ALREADY_EXISTS` | Calling `createWallet()` when wallet exists | Check `isNotCreated(wallet)` before calling `create()` | +| `CHAIN_NOT_SUPPORTED` | `switchChain` called with a chain not in `supportedChains` | Add the chain to `supportedChains` in `PrivyProvider` config | +| `INSECURE_CONTEXT` | WebCrypto unavailable (HTTP origin) | Use HTTPS or `localhost` for development | +| `PASSKEY_NOT_SUPPORTED` | Browser does not support WebAuthn | Feature-detect with `PublicKeyCredential` before showing passkey option | +| `OAUTH_POPUP_BLOCKED` | Browser blocked the OAuth popup window | Prompt user to allow popups for the domain | +| `LOGIN_CANCELLED` | User closed the login modal or cancelled OAuth | Silent reset. Not an error. | + +## Server-Side Errors (@privy-io/server-auth) + +| Error | Cause | Fix | +|-------|-------|-----| +| `Invalid token` | Token is malformed, expired, or wrong type | Check you are passing an access token (not identity token) to `verifyAuthToken` | +| `Invalid app ID` | App ID does not match the token's audience | Verify `PRIVY_APP_ID` matches the app that issued the token | +| `Unauthorized` | Missing or wrong app secret | Check `PRIVY_APP_SECRET` (starts with `secret-`) | +| `User not found` | Querying a non-existent user ID | Verify the user ID format (`did:privy:...`) | +| `Rate limited` | Too many API calls | Implement exponential backoff. Default limit: 100 req/sec per app | + +## Embedded Wallet Transaction Errors + +| Error Pattern | Cause | Fix | +|---------------|-------|-----| +| `User rejected the request` | User declined signing in the Privy popup | Silent reset to idle state. Not an error. | +| `insufficient funds` | Wallet balance too low for tx + gas | Show balance and required amount to user | +| `nonce too low` | Concurrent transactions with same nonce | Wait for pending tx to confirm before sending next | +| `execution reverted` | Smart contract reverted the call | Decode revert reason from error data. Check contract inputs. | +| `chain mismatch` | Wallet on wrong chain for the transaction | Call `wallet.switchChain(chainId)` before sending | + +## HTTP Status Codes (Server API) + +| Status | Meaning | Action | +|--------|---------|--------| +| 200 | Success | Process response | +| 400 | Bad request (malformed body) | Check request format | +| 401 | Unauthorized (invalid/expired token) | Re-authenticate client | +| 403 | Forbidden (valid token, wrong permissions) | Check app configuration | +| 404 | Resource not found | Verify user/wallet ID | +| 429 | Rate limited | Backoff and retry | +| 500 | Privy internal error | Retry with backoff. Report if persistent. | diff --git a/skills/privy/resources/sdk-reference.md b/skills/privy/resources/sdk-reference.md new file mode 100644 index 0000000..e0bfd02 --- /dev/null +++ b/skills/privy/resources/sdk-reference.md @@ -0,0 +1,100 @@ +# Privy SDK Reference + +> **Last verified:** March 2026 (`@privy-io/react-auth` v3.14.1, `@privy-io/server-auth` v1.14.x) + +Key hooks, methods, and types from the Privy React and server SDKs. + +## React Hooks + +| Hook | Import | Purpose | +|------|--------|---------| +| `usePrivy()` | `@privy-io/react-auth` | Auth state, login/logout, account linking | +| `useWallets()` | `@privy-io/react-auth` | All connected wallets (embedded + external) | +| `useEmbeddedWallet()` | `@privy-io/react-auth` | Embedded wallet creation, export, state | +| `useLoginWithEmail()` | `@privy-io/react-auth` | Headless email OTP login flow | +| `useLoginWithSms()` | `@privy-io/react-auth` | Headless SMS OTP login flow | +| `useLoginWithOAuth()` | `@privy-io/react-auth` | Headless OAuth login (Google, Apple, etc.) | +| `useLoginWithPasskey()` | `@privy-io/react-auth` | Headless WebAuthn passkey login | +| `useLoginWithWallet()` | `@privy-io/react-auth` | Headless external wallet login | +| `useLoginWithFarcaster()` | `@privy-io/react-auth` | Headless Farcaster SIWF login | +| `useLoginWithCustomAuth()` | `@privy-io/react-auth` | Headless custom JWT login | + +## usePrivy() Return Values + +| Property/Method | Type | Description | +|----------------|------|-------------| +| `ready` | `boolean` | SDK initialized and ready to use | +| `authenticated` | `boolean` | User is logged in | +| `user` | `PrivyUser \| null` | User object with linked accounts | +| `login()` | `() => void` | Opens the Privy login modal | +| `logout()` | `() => Promise` | Logs out and clears session | +| `getAccessToken()` | `() => Promise` | Returns a fresh access token (auto-refreshes) | +| `linkEmail()` | `() => void` | Link email to current account | +| `linkGoogle()` | `() => void` | Link Google to current account | +| `linkWallet()` | `() => void` | Link external wallet to current account | +| `linkPasskey()` | `() => void` | Link passkey to current account | +| `linkPhone()` | `() => void` | Link phone to current account | +| `linkDiscord()` | `() => void` | Link Discord to current account | +| `linkTwitter()` | `() => void` | Link Twitter/X to current account | +| `linkGithub()` | `() => void` | Link GitHub to current account | +| `unlinkEmail(address)` | `(address: string) => Promise` | Unlink email from account | +| `unlinkWallet(address)` | `(address: string) => Promise` | Unlink wallet from account | + +## useWallets() Return Values + +| Property | Type | Description | +|----------|------|-------------| +| `ready` | `boolean` | Wallets loaded and ready | +| `wallets` | `ConnectedWallet[]` | Array of all connected wallets | + +## ConnectedWallet Properties + +| Property/Method | Type | Description | +|----------------|------|-------------| +| `address` | `string` | Wallet address | +| `chainId` | `string` | Current chain ID (e.g., `"eip155:1"`) | +| `chainType` | `'ethereum' \| 'solana'` | Blockchain type | +| `walletClientType` | `string` | `'privy'`, `'privy_smart_wallet'`, `'metamask'`, etc. | +| `getEthereumProvider()` | `() => Promise` | EIP-1193 provider for viem/ethers | +| `getSolanaProvider()` | `() => Promise` | Solana provider (for Solana wallets) | +| `switchChain(chainId)` | `(chainId: number) => Promise` | Switch EVM chain | + +## useEmbeddedWallet() States + +| State Guard | Type | Description | +|------------|------|-------------| +| `isNotCreated(wallet)` | Type guard | Wallet not yet created. Call `wallet.create()`. | +| `isConnecting(wallet)` | Type guard | Wallet connecting after login. Wait. | +| `isConnected(wallet)` | Type guard | Wallet ready to use. Access `wallet.address`. | +| `isDisconnected(wallet)` | Type guard | Wallet disconnected. Re-login required. | + +## PrivyProvider Config + +| Config Key | Type | Default | Description | +|-----------|------|---------|-------------| +| `loginMethods` | `string[]` | `['email']` | Enabled auth methods | +| `appearance.theme` | `'light' \| 'dark'` | `'light'` | Modal theme | +| `appearance.accentColor` | `string` | `'#6366f1'` | Accent color for modal | +| `appearance.logo` | `string` | None | URL of logo shown in modal | +| `embeddedWallets.createOnLogin` | `'off' \| 'users-without-wallets' \| 'all-users'` | `'off'` | Auto-create embedded wallet | +| `embeddedWallets.requireUserPasswordOnCreate` | `boolean` | `false` | Require password for recovery share | +| `smartWallets.enabled` | `boolean` | `false` | Enable Safe-based smart wallets | +| `defaultChain` | `Chain` | First in `supportedChains` | Default EVM chain | +| `supportedChains` | `Chain[]` | All chains | Allowed EVM chains | + +## Server SDK (PrivyClient) + +| Method | Signature | Description | +|--------|-----------|-------------| +| `verifyAuthToken(token)` | `(token: string) => Promise` | Verify access token. Returns user ID. | +| `getUser({ idToken })` | `(opts: { idToken: string }) => Promise` | Get user profile from identity token | +| `getUser({ userId })` | `(opts: { userId: string }) => Promise` | Get user profile by Privy user ID | +| `deleteUser(userId)` | `(userId: string) => Promise` | Delete a user | + +## Packages + +| Package | Purpose | Install | +|---------|---------|---------| +| `@privy-io/react-auth` | React SDK (client-side) | `npm install @privy-io/react-auth` | +| `@privy-io/server-auth` | Server-side JWT verification | `npm install @privy-io/server-auth` | +| `@privy-io/expo` | React Native / Expo SDK | `npm install @privy-io/expo` | From 800931cec68c2b9f876b1fe1a134d6b8ef08703b Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:08:14 -0800 Subject: [PATCH 08/20] feat: add privy starter template --- skills/privy/templates/privy-provider.tsx | 46 +++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 skills/privy/templates/privy-provider.tsx diff --git a/skills/privy/templates/privy-provider.tsx b/skills/privy/templates/privy-provider.tsx new file mode 100644 index 0000000..58e13d0 --- /dev/null +++ b/skills/privy/templates/privy-provider.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { PrivyProvider } from "@privy-io/react-auth"; +import { mainnet, base, arbitrum, optimism, polygon } from "viem/chains"; +import type { ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} From e871375718c1a361017bddea2e458a3a44335a17 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:21:47 -0800 Subject: [PATCH 09/20] feat: add farcaster skill --- skills/farcaster/SKILL.md | 591 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 skills/farcaster/SKILL.md diff --git a/skills/farcaster/SKILL.md b/skills/farcaster/SKILL.md new file mode 100644 index 0000000..c6af5f6 --- /dev/null +++ b/skills/farcaster/SKILL.md @@ -0,0 +1,591 @@ +--- +name: farcaster +description: "Onchain social protocol with Neynar API, Frames v2 Mini Apps, and transaction frames. Covers Snapchain architecture, FID registry on OP Mainnet, and Warpcast integration." +license: Apache-2.0 +metadata: + author: 0xinit + version: "1.0" + chain: multichain + category: Infrastructure +tags: + - farcaster + - neynar + - frames + - mini-apps + - social + - social-graph + - warpcast + - op-mainnet + - snapchain +--- + +# Farcaster + +Farcaster is a sufficiently decentralized social protocol. Users register onchain identities (FIDs) on OP Mainnet and publish social data (casts, reactions, links) as offchain messages to Snapchain, a purpose-built message ordering layer. Neynar provides the primary API infrastructure and, since January 2026, owns the Farcaster protocol itself. Frames v2 (Mini Apps) enable full-screen interactive web applications embedded inside Farcaster clients like Warpcast. + +## What You Probably Got Wrong + +- **Manifest `accountAssociation` domain MUST exactly match the FQDN where `/.well-known/farcaster.json` is hosted.** A mismatch causes silent failure -- the Mini App will not load, no error is surfaced to the developer, and Warpcast simply shows nothing. The domain in the signature payload must be byte-identical to the hosting domain (no trailing slash, no protocol prefix, no port unless non-standard). + +- **Neynar webhooks MUST be verified via HMAC-SHA512 at write time.** Check the `X-Neynar-Signature` header against the raw request body. Never parse JSON before verification -- you must verify the raw bytes. + +```typescript +import crypto from "node:crypto"; +import type { IncomingHttpHeaders } from "node:http"; + +function verifyNeynarWebhook( + rawBody: Buffer, + headers: IncomingHttpHeaders, + webhookSecret: string +): boolean { + const signature = headers["x-neynar-signature"]; + if (typeof signature !== "string") return false; + + const hmac = crypto.createHmac("sha512", webhookSecret); + hmac.update(rawBody); + const computedSignature = hmac.digest("hex"); + + return crypto.timingSafeEqual( + Buffer.from(signature, "hex"), + Buffer.from(computedSignature, "hex") + ); +} +``` + +- **Farcaster is NOT a blockchain.** It is a social protocol with an onchain registry (OP Mainnet) for identity and key management, plus an offchain message layer (Snapchain) for social data. Casts, reactions, and follows are never posted to any blockchain. + +- **FIDs are onchain but casts are NOT.** Farcaster IDs (FIDs) live in the IdRegistry contract on OP Mainnet. Casts, reactions, and link messages are stored on Snapchain and are not onchain data. + +- **Frames v2 is NOT Frames v1 -- completely different spec.** Frames v1 used static OG images with action buttons and server-side rendering. Frames v2 (Mini Apps) are full-screen interactive web applications loaded in an iframe with SDK access to wallet, user context, and notifications. Do not mix the two APIs. + +- **Neynar is NOT just an API provider.** Neynar acquired Farcaster from Merkle Manufactory in January 2026. Neynar now owns and operates the protocol, the Snapchain infrastructure, and the primary API layer. + +- **Frame images must be static.** Frame preview images (OG images shown in feed) cannot contain JavaScript. They are rendered as static images by the client. Interactive behavior only works inside the launched Mini App. + +- **`@farcaster/frame-sdk` and `@farcaster/miniapp-sdk` are converging.** Both packages exist but `frame-sdk` is the current stable package for Frames v2. Check import paths -- functionality overlaps but the packages are not yet unified. + +- **Farcaster timestamps use a custom epoch.** Timestamps are seconds since January 1, 2021 00:00:00 UTC (Farcaster epoch), not Unix epoch. To convert: `unixTimestamp = farcasterTimestamp + 1609459200`. + +- **Cast text has a 1024 BYTE limit, not characters.** UTF-8 multibyte characters (emoji, CJK, accented characters) consume 2-4 bytes each. A 1024-character cast with emoji will exceed the limit. + +- **Warpcast aggressively caches OG/frame images.** Changing content at the same URL will not update the preview in Warpcast feeds. Use cache-busting query parameters or new URLs when updating frame images. + +## Critical Context + +Neynar acquired Farcaster from Merkle Manufactory in January 2026. This means: + +- Neynar operates the protocol, Snapchain validators, and the Hub network +- The Neynar API is the canonical way to interact with Farcaster +- Warpcast remains the primary client, now under Neynar's umbrella +- The open-source protocol spec and hub software remain MIT-licensed +- Third-party hubs can still run, but Neynar controls the reference implementation + +## Protocol Architecture + +### Snapchain + +Snapchain replaced the Hub network in April 2025 as Farcaster's offchain message ordering layer. + +| Property | Detail | +|----------|--------| +| Consensus | Malachite BFT (Tendermint-derived) | +| Throughput | 10,000+ messages per second | +| Sharding | Account-level -- each FID's messages are ordered independently | +| Finality | Sub-second for message acceptance | +| Data model | Append-only log of signed messages per FID | +| Validator set | Operated by Neynar (post-acquisition) | + +Messages on Snapchain are CRDTs (Conflict-free Replicated Data Types). Each message type has merge rules that ensure consistency across nodes without coordination: + +- **CastAdd** conflicts with a later **CastRemove** for the same hash -- remove wins +- **ReactionAdd** conflicts with **ReactionRemove** for the same target -- last-write-wins by timestamp +- **LinkAdd** conflicts with **LinkRemove** -- last-write-wins by timestamp + +### Message Structure + +Every Farcaster message is an Ed25519-signed protobuf: + +``` +MessageData { + type: MessageType // CAST_ADD, REACTION_ADD, LINK_ADD, etc. + fid: uint64 // Farcaster ID of the author + timestamp: uint32 // Farcaster epoch seconds + network: Network // MAINNET = 1 + body: MessageBody // Type-specific payload +} + +Message { + data: MessageData + hash: bytes // Blake3 hash of serialized MessageData + hash_scheme: BLAKE3 + signature: bytes // Ed25519 signature over hash + signature_scheme: ED25519 + signer: bytes // Public key of the signer (app key) +} +``` + +## Onchain Registry (OP Mainnet) + +Farcaster's onchain contracts manage identity, keys, and storage on OP Mainnet. + +> **Last verified:** March 2026 + +| Contract | Address | Purpose | +|----------|---------|---------| +| IdRegistry | `0x00000000Fc6c5F01Fc30151999387Bb99A9f489b` | Maps FIDs to custody addresses | +| KeyRegistry | `0x00000000Fc1237824fb747aBDE0FF18990E59b7e` | Maps FIDs to Ed25519 app keys (signers) | +| StorageRegistry | `0x00000000FcCe7f938e7aE6D3c335bD6a1a7c593D` | Manages storage units per FID | +| IdGateway | `0x00000000Fc25870C6eD6b6c7E41Fb078b7656f69` | Permissioned FID registration entry point | +| KeyGateway | `0x00000000fC56947c7E7183f8Ca4B62398CaaDF0B` | Permissioned key addition entry point | +| Bundler | `0x00000000FC04c910A0b5feA33b03E0447ad0B0aA` | Batches register + addKey + rent in one tx | + +```bash +# Verify IdRegistry is deployed on OP Mainnet +cast code 0x00000000Fc6c5F01Fc30151999387Bb99A9f489b --rpc-url https://mainnet.optimism.io + +# Look up custody address for an FID +cast call 0x00000000Fc6c5F01Fc30151999387Bb99A9f489b \ + "custodyOf(uint256)(address)" 3 \ + --rpc-url https://mainnet.optimism.io +``` + +### Registration Flow + +``` +1. User calls IdGateway.register() or Bundler.register() + -> IdRegistry assigns next sequential FID to custody address + | +2. User (or Bundler) calls KeyGateway.add() + -> KeyRegistry maps FID to an Ed25519 public key (app key / signer) + | +3. User (or Bundler) calls StorageRegistry.rent() + -> Allocates storage units (each unit = 5,000 casts, 2,500 reactions, 2,500 links) + | +4. App key can now sign Farcaster messages on behalf of the FID +``` + +## Farcaster IDs (FIDs) + +Every Farcaster user has an FID -- a sequentially assigned `uint256` stored in IdRegistry on OP Mainnet. + +| Concept | Description | +|---------|-------------| +| FID | The user's numeric identity, immutable once assigned | +| Custody address | The Ethereum address that owns the FID -- can transfer ownership | +| App key (signer) | Ed25519 key pair registered in KeyRegistry -- signs messages | +| Recovery address | Can initiate FID recovery if custody address is compromised | + +An FID can have multiple app keys. Each app (Warpcast, third-party client) registers its own app key via KeyGateway. The custody address can revoke any app key by calling KeyRegistry.remove(). + +## Neynar API v2 + +Neynar provides the primary API for reading and writing Farcaster data. Current SDK version: `@neynar/nodejs-sdk` v3.131.0. + +### Setup + +```bash +npm install @neynar/nodejs-sdk +``` + +```typescript +import { NeynarAPIClient, Configuration } from "@neynar/nodejs-sdk"; + +const config = new Configuration({ + apiKey: process.env.NEYNAR_API_KEY, +}); + +const neynar = new NeynarAPIClient(config); +``` + +### Fetch User by FID + +```typescript +const { users } = await neynar.fetchBulkUsers({ fids: [3] }); +const user = users[0]; +console.log(user.username, user.display_name, user.follower_count); +``` + +### Publish a Cast + +```typescript +const response = await neynar.publishCast({ + signerUuid: process.env.SIGNER_UUID, + text: "Hello from Neynar SDK", +}); +console.log(response.cast.hash); +``` + +### Fetch Feed + +```typescript +const feed = await neynar.fetchFeed({ + feedType: "following", + fid: 3, + limit: 25, +}); + +for (const cast of feed.casts) { + console.log(`@${cast.author.username}: ${cast.text}`); +} +``` + +### Search Users + +```typescript +const result = await neynar.searchUser({ q: "vitalik", limit: 5 }); +for (const user of result.result.users) { + console.log(`FID ${user.fid}: @${user.username}`); +} +``` + +### Fetch Cast by Hash + +```typescript +const { cast } = await neynar.lookupCastByHashOrWarpcastUrl({ + identifier: "0xfe90f9de682273e05b201629ad2338bdcd89b6be", + type: "hash", +}); +console.log(cast.text, cast.reactions.likes_count); +``` + +### Webhook Configuration + +Create webhooks in the Neynar dashboard or via API. Webhooks fire on cast creation, reaction events, follow events, and more. + +```typescript +import express from "express"; +import crypto from "node:crypto"; + +const app = express(); + +// Raw body is required for signature verification +app.use("/webhook", express.raw({ type: "application/json" })); + +app.post("/webhook", (req, res) => { + const rawBody = req.body as Buffer; + const signature = req.headers["x-neynar-signature"] as string; + + if (!signature) { + res.status(401).json({ error: "Missing signature" }); + return; + } + + const hmac = crypto.createHmac("sha512", process.env.NEYNAR_WEBHOOK_SECRET!); + hmac.update(rawBody); + const computed = hmac.digest("hex"); + + const isValid = crypto.timingSafeEqual( + Buffer.from(signature, "hex"), + Buffer.from(computed, "hex") + ); + + if (!isValid) { + res.status(401).json({ error: "Invalid signature" }); + return; + } + + const event = JSON.parse(rawBody.toString("utf-8")); + console.log("Verified webhook event:", event.type); + + res.status(200).json({ status: "ok" }); +}); + +app.listen(3001, () => console.log("Webhook listener on :3001")); +``` + +## Frames v2 / Mini Apps + +Frames v2 are full-screen interactive web applications embedded inside Farcaster clients. They replaced the static image + button model of Frames v1 with a rich SDK-powered experience. + +### Manifest (`/.well-known/farcaster.json`) + +Every Mini App must serve a manifest at `/.well-known/farcaster.json` on its domain: + +```json +{ + "accountAssociation": { + "header": "eyJmaWQiOjM...", + "payload": "eyJkb21haW4iOiJleGFtcGxlLmNvbSJ9", + "signature": "abc123..." + }, + "frame": { + "version": "1", + "name": "My Mini App", + "iconUrl": "https://example.com/icon.png", + "homeUrl": "https://example.com/app", + "splashImageUrl": "https://example.com/splash.png", + "splashBackgroundColor": "#1a1a2e", + "webhookUrl": "https://example.com/api/webhook" + } +} +``` + +The `accountAssociation` proves that the FID owner controls the domain. The `payload` decoded is `{"domain":"example.com"}` -- this domain MUST match the FQDN hosting the manifest file. + +### Meta Tags + +Add these to your app's HTML `` for Farcaster clients to discover the Mini App: + +```html + +``` + +### Frame SDK Setup + +```bash +npm install @farcaster/frame-sdk +``` + +```typescript +import sdk from "@farcaster/frame-sdk"; + +async function initMiniApp() { + const context = await sdk.context; + + // context.user contains the viewing user's FID, username, pfpUrl + console.log(`User FID: ${context.user.fid}`); + console.log(`Username: ${context.user.username}`); + + // Signal to the client that the app is ready to render + sdk.actions.ready(); +} + +initMiniApp(); +``` + +### SDK Actions + +```typescript +// Open an external URL in the client's browser +sdk.actions.openUrl("https://example.com"); + +// Close the Mini App +sdk.actions.close(); + +// Compose a cast with prefilled text +sdk.actions.composeCast({ + text: "Check out this Mini App!", + embeds: ["https://example.com/app"], +}); + +// Add a Mini App to the user's favorites (prompts confirmation) +sdk.actions.addFrame(); +``` + +## Transaction Frames + +Mini Apps can trigger onchain transactions through the embedded wallet provider. The SDK exposes an EIP-1193 provider that connects to the user's wallet in the Farcaster client. + +### Wallet Provider Setup + +```typescript +import sdk from "@farcaster/frame-sdk"; +import { createWalletClient, custom, parseEther, type Address } from "viem"; +import { base } from "viem/chains"; + +async function sendTransaction() { + const context = await sdk.context; + + const provider = sdk.wallet.ethProvider; + + const walletClient = createWalletClient({ + chain: base, + transport: custom(provider), + }); + + const [address] = await walletClient.requestAddresses(); + + const hash = await walletClient.sendTransaction({ + account: address, + to: "0xRecipient..." as Address, + value: parseEther("0.001"), + }); + + return hash; +} +``` + +### With Wagmi Connector + +For apps using wagmi, wrap the SDK's provider as a connector: + +```typescript +import sdk from "@farcaster/frame-sdk"; +import { createConfig, http, useConnect, useSendTransaction } from "wagmi"; +import { base } from "wagmi/chains"; +import { farcasterFrame } from "@farcaster/frame-wagmi-connector"; + +const config = createConfig({ + chains: [base], + transports: { + [base.id]: http(), + }, + connectors: [farcasterFrame()], +}); + +// In your React component: +function MintButton() { + const { connect, connectors } = useConnect(); + const { sendTransaction } = useSendTransaction(); + + async function handleMint() { + connect({ connector: connectors[0] }); + sendTransaction({ + to: "0xNFTContract..." as `0x${string}`, + data: "0x...", // mint function calldata + value: parseEther("0.01"), + }); + } + + return ; +} +``` + +## Warpcast Deep Links and Cast Intents + +### Cast Intent URL + +Open Warpcast's compose screen with prefilled content: + +``` +https://warpcast.com/~/compose?text=Hello%20Farcaster&embeds[]=https://example.com +``` + +| Parameter | Description | +|-----------|-------------| +| `text` | URL-encoded cast text | +| `embeds[]` | Up to 2 embed URLs | +| `channelKey` | Channel to post in (e.g., `farcaster`) | + +### Deep Links + +``` +# Open a user's profile +https://warpcast.com/ + +# Open a specific cast +https://warpcast.com// + +# Open a channel +https://warpcast.com/~/channel/ + +# Open direct cast composer +https://warpcast.com/~/inbox/create/ +``` + +## Channels + +Channels are topic-based feeds identified by a `parent_url`. A cast is posted to a channel by setting its `parent_url` to the channel's URL. + +```typescript +// Post a cast to the "ethereum" channel +const response = await neynar.publishCast({ + signerUuid: process.env.SIGNER_UUID, + text: "Pectra upgrade is live!", + channelId: "ethereum", +}); +``` + +### Channel Lookup + +```typescript +const channel = await neynar.lookupChannel({ id: "farcaster" }); +console.log(channel.channel.name, channel.channel.follower_count); +``` + +### Channel Feed + +```typescript +const feed = await neynar.fetchFeed({ + feedType: "filter", + filterType: "channel_id", + channelId: "ethereum", + limit: 25, +}); +``` + +## Neynar API Pricing + +> Current as of March 2026 + +| Plan | Monthly Credits | Price | Webhooks | Rate Limit | +|------|----------------|-------|----------|------------| +| Free | 100K | $0 | 1 | 5 req/s | +| Starter | 1M | $49/mo | 5 | 20 req/s | +| Growth | 10M | $249/mo | 25 | 50 req/s | +| Scale | 60M | $899/mo | 100 | 200 req/s | +| Enterprise | Custom | Custom | Unlimited | Custom | + +Credit costs vary by endpoint. Read operations (user lookup, feed) cost 1-5 credits. Write operations (publish cast, react) cost 10-50 credits. Webhook deliveries are free but count against webhook limits. + +## Hub / Snapchain Endpoints + +Direct hub access for reading raw Farcaster data without the Neynar API abstraction. + +| Provider | Endpoint | Auth | +|----------|----------|------| +| Neynar Hub API | `hub-api.neynar.com` | API key in `x-api-key` header | +| Self-hosted Hub | `localhost:2283` | None (local) | + +### Hub HTTP API Examples + +```bash +# Get casts by FID +curl -H "x-api-key: $NEYNAR_API_KEY" \ + "https://hub-api.neynar.com/v1/castsByFid?fid=3&pageSize=10" + +# Get user data (display name, bio, pfp) +curl -H "x-api-key: $NEYNAR_API_KEY" \ + "https://hub-api.neynar.com/v1/userDataByFid?fid=3" + +# Get reactions by FID +curl -H "x-api-key: $NEYNAR_API_KEY" \ + "https://hub-api.neynar.com/v1/reactionsByFid?fid=3&reactionType=1" +``` + +### Hub gRPC API + +```bash +# Install hubble CLI +npm install -g @farcaster/hubble + +# Query via gRPC +hubble --insecure -r hub-api.neynar.com:2283 getCastsByFid --fid 3 +``` + +## Farcaster Epoch Conversion + +```typescript +// Farcaster epoch: January 1, 2021 00:00:00 UTC +const FARCASTER_EPOCH = 1609459200; + +function farcasterTimestampToUnix(farcasterTs: number): number { + return farcasterTs + FARCASTER_EPOCH; +} + +function unixToFarcasterTimestamp(unixTs: number): number { + return unixTs - FARCASTER_EPOCH; +} + +function farcasterTimestampToDate(farcasterTs: number): Date { + return new Date((farcasterTs + FARCASTER_EPOCH) * 1000); +} +``` + +## Related Skills + +- **viem** -- Used for onchain interactions with Farcaster registry contracts on OP Mainnet and for building transaction frames with the wallet provider +- **wagmi** -- React hooks for wallet connection in Mini Apps via the `@farcaster/frame-wagmi-connector` +- **x402** -- Payment protocol that can be integrated with Farcaster Mini Apps for paywalled content + +## References + +- [Farcaster Protocol Spec](https://github.com/farcasterxyz/protocol) +- [Farcaster Frames v2 Spec](https://docs.farcaster.xyz/developers/frames/v2/spec) +- [Neynar API Docs](https://docs.neynar.com) +- [Neynar Node.js SDK](https://github.com/neynar/nodejs-sdk) +- [`@farcaster/frame-sdk` (npm)](https://www.npmjs.com/package/@farcaster/frame-sdk) +- [Farcaster Contracts (GitHub)](https://github.com/farcasterxyz/contracts) +- [Warpcast](https://warpcast.com) +- [Farcaster IdRegistry (OP Mainnet)](https://optimistic.etherscan.io/address/0x00000000Fc6c5F01Fc30151999387Bb99A9f489b) +- [Snapchain Architecture](https://github.com/farcasterxyz/snapchain) From c532a517ce2f965a33fc80b54926d6ed21d52243 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:56:32 -0800 Subject: [PATCH 10/20] feat: add farcaster examples --- skills/farcaster/examples/mini-app/README.md | 182 +++++++++++++++ .../farcaster/examples/neynar-feed/README.md | 170 ++++++++++++++ skills/farcaster/examples/tx-frame/README.md | 215 ++++++++++++++++++ .../examples/webhook-listener/README.md | 195 ++++++++++++++++ 4 files changed, 762 insertions(+) create mode 100644 skills/farcaster/examples/mini-app/README.md create mode 100644 skills/farcaster/examples/neynar-feed/README.md create mode 100644 skills/farcaster/examples/tx-frame/README.md create mode 100644 skills/farcaster/examples/webhook-listener/README.md diff --git a/skills/farcaster/examples/mini-app/README.md b/skills/farcaster/examples/mini-app/README.md new file mode 100644 index 0000000..f54751e --- /dev/null +++ b/skills/farcaster/examples/mini-app/README.md @@ -0,0 +1,182 @@ +# Frames v2 Mini App + +Working TypeScript example for building a Farcaster Mini App with the Frame SDK, manifest setup, and user context access. + +## Dependencies + +```bash +npm install @farcaster/frame-sdk +``` + +## Manifest (`/.well-known/farcaster.json`) + +Host this JSON at your domain's `/.well-known/farcaster.json` path. The `accountAssociation` must be generated by the FID owner for the exact domain where this file is served. + +```json +{ + "accountAssociation": { + "header": "eyJmaWQiOjEyMzQ1LCJ0eXBlIjoiY3VzdG9keSIsImtleSI6IjB4Li4uIn0", + "payload": "eyJkb21haW4iOiJteWFwcC5leGFtcGxlLmNvbSJ9", + "signature": "MHg..." + }, + "frame": { + "version": "1", + "name": "My Mini App", + "iconUrl": "https://myapp.example.com/icon.png", + "homeUrl": "https://myapp.example.com/app", + "splashImageUrl": "https://myapp.example.com/splash.png", + "splashBackgroundColor": "#1a1a2e", + "webhookUrl": "https://myapp.example.com/api/webhook" + } +} +``` + +The decoded `payload` is `{"domain":"myapp.example.com"}`. This domain MUST match the FQDN hosting this file -- mismatch causes silent failure. + +## HTML Entry Point + +```html + + + + + + + My Mini App + + +
+ + + +``` + +## SDK Initialization + +```typescript +import sdk from "@farcaster/frame-sdk"; + +interface AppContext { + fid: number; + username: string; + displayName: string; + pfpUrl: string; +} + +async function initApp(): Promise { + const context = await sdk.context; + + const appContext: AppContext = { + fid: context.user.fid, + username: context.user.username ?? "unknown", + displayName: context.user.displayName ?? "Anonymous", + pfpUrl: context.user.pfpUrl ?? "", + }; + + // Signal readiness -- the client removes the splash screen after this + sdk.actions.ready(); + + return appContext; +} +``` + +## SDK Actions + +```typescript +async function shareApp() { + sdk.actions.composeCast({ + text: "Check out this Mini App!", + embeds: ["https://myapp.example.com"], + }); +} + +function openLink(url: string) { + sdk.actions.openUrl(url); +} + +function closeApp() { + sdk.actions.close(); +} + +async function promptAddToFavorites() { + sdk.actions.addFrame(); +} +``` + +## Notification Context + +Mini Apps can check if the user has notifications enabled: + +```typescript +async function checkNotifications() { + const context = await sdk.context; + + if (context.client.notificationDetails) { + const { url, token } = context.client.notificationDetails; + console.log(`Notifications enabled: ${url}`); + + // Store the token server-side to send push notifications later + await fetch("/api/register-notifications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fid: context.user.fid, url, token }), + }); + } +} +``` + +## Complete Mini App + +```typescript +import sdk from "@farcaster/frame-sdk"; + +async function main() { + const context = await sdk.context; + const user = context.user; + + const app = document.getElementById("app")!; + app.innerHTML = ` +
+
+ +
+
${user.displayName}
+
@${user.username} (FID: ${user.fid})
+
+
+ + +
+ `; + + document.getElementById("share-btn")!.addEventListener("click", () => { + sdk.actions.composeCast({ + text: `I'm using this Mini App!`, + embeds: ["https://myapp.example.com"], + }); + }); + + document.getElementById("add-btn")!.addEventListener("click", () => { + sdk.actions.addFrame(); + }); + + sdk.actions.ready(); +} + +main().catch(console.error); +``` + +## Notes + +- The `fc:frame` meta tag must be valid JSON in the `content` attribute -- malformed JSON silently breaks frame detection +- `sdk.actions.ready()` must be called for the splash screen to dismiss. If omitted, the app appears stuck on the splash screen +- Mini Apps run in a sandboxed iframe -- `window.top` access is blocked +- The `context.user` object is provided by the hosting client (Warpcast) and represents the currently logged-in user viewing the app +- Test locally with the Warpcast developer tools or the Frames Debugger at `https://debugger.framesjs.org` diff --git a/skills/farcaster/examples/neynar-feed/README.md b/skills/farcaster/examples/neynar-feed/README.md new file mode 100644 index 0000000..784716f --- /dev/null +++ b/skills/farcaster/examples/neynar-feed/README.md @@ -0,0 +1,170 @@ +# Fetch and Display a Farcaster Feed + +Working TypeScript example for fetching a user's Farcaster feed using the Neynar API v2 SDK, including user lookup and cast rendering. + +## Dependencies + +```bash +npm install @neynar/nodejs-sdk +``` + +## Setup + +```typescript +import { NeynarAPIClient, Configuration } from "@neynar/nodejs-sdk"; + +const config = new Configuration({ + apiKey: process.env.NEYNAR_API_KEY, +}); + +const neynar = new NeynarAPIClient(config); +``` + +## Fetch User Profile + +```typescript +interface UserProfile { + fid: number; + username: string; + displayName: string; + bio: string; + followerCount: number; + followingCount: number; + pfpUrl: string; +} + +async function getUserProfile(fid: number): Promise { + const { users } = await neynar.fetchBulkUsers({ fids: [fid] }); + if (users.length === 0) { + throw new Error(`No user found for FID ${fid}`); + } + + const user = users[0]; + return { + fid: user.fid, + username: user.username, + displayName: user.display_name, + bio: user.profile.bio.text, + followerCount: user.follower_count, + followingCount: user.following_count, + pfpUrl: user.pfp_url, + }; +} +``` + +## Fetch Following Feed + +Retrieves casts from users that the specified FID follows. + +```typescript +// Farcaster epoch: January 1, 2021 00:00:00 UTC +const FARCASTER_EPOCH = 1609459200; + +interface FeedCast { + hash: string; + author: string; + text: string; + timestamp: Date; + likes: number; + recasts: number; + replies: number; + embeds: string[]; +} + +async function getFollowingFeed( + fid: number, + limit: number = 25 +): Promise { + const feed = await neynar.fetchFeed({ + feedType: "following", + fid, + limit, + }); + + return feed.casts.map((cast) => ({ + hash: cast.hash, + author: `@${cast.author.username}`, + text: cast.text, + timestamp: new Date((cast.timestamp + FARCASTER_EPOCH) * 1000), + likes: cast.reactions.likes_count, + recasts: cast.reactions.recasts_count, + replies: cast.replies.count, + embeds: cast.embeds.map((e) => e.url).filter(Boolean), + })); +} +``` + +## Fetch Channel Feed + +```typescript +async function getChannelFeed( + channelId: string, + limit: number = 25 +): Promise { + const feed = await neynar.fetchFeed({ + feedType: "filter", + filterType: "channel_id", + channelId, + limit, + }); + + return feed.casts.map((cast) => ({ + hash: cast.hash, + author: `@${cast.author.username}`, + text: cast.text, + timestamp: new Date((cast.timestamp + FARCASTER_EPOCH) * 1000), + likes: cast.reactions.likes_count, + recasts: cast.reactions.recasts_count, + replies: cast.replies.count, + embeds: cast.embeds.map((e) => e.url).filter(Boolean), + })); +} +``` + +## Search Users + +```typescript +async function searchUsers(query: string, limit: number = 5) { + const result = await neynar.searchUser({ q: query, limit }); + + return result.result.users.map((user) => ({ + fid: user.fid, + username: user.username, + displayName: user.display_name, + followerCount: user.follower_count, + })); +} +``` + +## Complete Usage + +```typescript +async function main() { + const profile = await getUserProfile(3); + console.log(`${profile.displayName} (@${profile.username})`); + console.log(`Followers: ${profile.followerCount}`); + + console.log("\n--- Following Feed ---"); + const feed = await getFollowingFeed(3, 10); + for (const cast of feed) { + console.log(`${cast.author} (${cast.timestamp.toISOString()})`); + console.log(` ${cast.text.slice(0, 100)}`); + console.log(` Likes: ${cast.likes} | Recasts: ${cast.recasts} | Replies: ${cast.replies}`); + } + + console.log("\n--- Ethereum Channel ---"); + const channelFeed = await getChannelFeed("ethereum", 5); + for (const cast of channelFeed) { + console.log(`${cast.author}: ${cast.text.slice(0, 80)}`); + } +} + +main().catch(console.error); +``` + +## Notes + +- The Neynar API key must be set in `NEYNAR_API_KEY` environment variable +- Feed responses are paginated -- use the `cursor` field from the response for subsequent pages +- Rate limits depend on your Neynar plan (Free: 5 req/s, Starter: 20 req/s) +- Timestamps from the Neynar API may be returned as Farcaster epoch seconds or ISO strings depending on the endpoint -- always check the response format diff --git a/skills/farcaster/examples/tx-frame/README.md b/skills/farcaster/examples/tx-frame/README.md new file mode 100644 index 0000000..3988c2a --- /dev/null +++ b/skills/farcaster/examples/tx-frame/README.md @@ -0,0 +1,215 @@ +# Transaction Frame + +Working TypeScript example for a Farcaster Mini App that triggers an onchain transaction using the Frame SDK wallet provider and viem. + +## Dependencies + +```bash +npm install @farcaster/frame-sdk viem +``` + +## Wallet Provider Setup + +The Frame SDK exposes an EIP-1193 compatible Ethereum provider through `sdk.wallet.ethProvider`. This connects to the user's wallet managed by the Farcaster client. + +```typescript +import sdk from "@farcaster/frame-sdk"; +import { + createWalletClient, + createPublicClient, + custom, + http, + parseEther, + encodeFunctionData, + type Address, + type Hash, +} from "viem"; +import { base } from "viem/chains"; + +async function setupWallet() { + const context = await sdk.context; + sdk.actions.ready(); + + const provider = sdk.wallet.ethProvider; + + const walletClient = createWalletClient({ + chain: base, + transport: custom(provider), + }); + + const publicClient = createPublicClient({ + chain: base, + transport: http(), + }); + + const [address] = await walletClient.requestAddresses(); + + return { walletClient, publicClient, address }; +} +``` + +## Send ETH + +```typescript +async function sendEth(to: Address, amount: string): Promise { + const { walletClient, publicClient, address } = await setupWallet(); + + const hash = await walletClient.sendTransaction({ + account: address, + to, + value: parseEther(amount), + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`Transaction reverted: ${hash}`); + } + + return hash; +} +``` + +## Contract Interaction (Mint NFT) + +```typescript +const NFT_CONTRACT = "0xNFTContract..." as const; + +const mintAbi = [ + { + type: "function", + name: "mint", + inputs: [{ name: "to", type: "address" }], + outputs: [{ name: "tokenId", type: "uint256" }], + stateMutability: "payable", + }, +] as const; + +async function mintNft(): Promise<{ hash: Hash; tokenId: bigint }> { + const { walletClient, publicClient, address } = await setupWallet(); + + const hash = await walletClient.sendTransaction({ + account: address, + to: NFT_CONTRACT, + value: parseEther("0.001"), + data: encodeFunctionData({ + abi: mintAbi, + functionName: "mint", + args: [address], + }), + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`Mint reverted: ${hash}`); + } + + const mintLog = receipt.logs[0]; + const tokenId = BigInt(mintLog.topics[3] ?? "0"); + + return { hash, tokenId }; +} +``` + +## ERC-20 Token Transfer + +```typescript +const ERC20_ADDRESS = "0xTokenContract..." as const; + +const erc20Abi = [ + { + type: "function", + name: "transfer", + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + }, +] as const; + +async function transferTokens( + to: Address, + amount: bigint +): Promise { + const { walletClient, publicClient, address } = await setupWallet(); + + const hash = await walletClient.sendTransaction({ + account: address, + to: ERC20_ADDRESS, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [to, amount], + }), + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`Transfer reverted: ${hash}`); + } + + return hash; +} +``` + +## Complete Mini App with Transaction UI + +```typescript +import sdk from "@farcaster/frame-sdk"; +import { createWalletClient, custom, parseEther, type Address } from "viem"; +import { base } from "viem/chains"; + +async function main() { + const context = await sdk.context; + const provider = sdk.wallet.ethProvider; + + const walletClient = createWalletClient({ + chain: base, + transport: custom(provider), + }); + + const [address] = await walletClient.requestAddresses(); + + const app = document.getElementById("app")!; + app.innerHTML = ` +
+

Connected: ${address.slice(0, 6)}...${address.slice(-4)}

+

User: @${context.user.username} (FID: ${context.user.fid})

+ +

+
+ `; + + document.getElementById("send-btn")!.addEventListener("click", async () => { + const status = document.getElementById("status")!; + status.textContent = "Confirming..."; + + try { + const hash = await walletClient.sendTransaction({ + account: address, + to: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" as Address, + value: parseEther("0.001"), + }); + status.textContent = `Sent! Hash: ${hash}`; + } catch (err) { + status.textContent = `Error: ${err instanceof Error ? err.message : "Unknown"}`; + } + }); + + sdk.actions.ready(); +} + +main().catch(console.error); +``` + +## Notes + +- The wallet provider is only available inside a Farcaster client (Warpcast). Testing outside a client will throw +- `sdk.wallet.ethProvider` follows EIP-1193 -- it works with viem's `custom()` transport and wagmi connectors +- Users must confirm transactions in their Farcaster client's wallet UI before they are broadcast +- Always check `receipt.status` after `waitForTransactionReceipt` -- a mined transaction can still have reverted +- The connected chain depends on the Farcaster client's wallet configuration. Use `wallet_switchEthereumChain` to request a specific chain if needed +- Token amounts must use `bigint`, not JavaScript `number`, to avoid precision loss diff --git a/skills/farcaster/examples/webhook-listener/README.md b/skills/farcaster/examples/webhook-listener/README.md new file mode 100644 index 0000000..6e305aa --- /dev/null +++ b/skills/farcaster/examples/webhook-listener/README.md @@ -0,0 +1,195 @@ +# Neynar Webhook Listener + +Working TypeScript example for an Express server that receives Neynar webhook events with complete HMAC-SHA512 signature verification. + +## Dependencies + +```bash +npm install express @neynar/nodejs-sdk +npm install -D @types/express typescript +``` + +## Webhook Signature Verification + +Neynar signs every webhook delivery with HMAC-SHA512 using your webhook secret. The signature is sent in the `X-Neynar-Signature` header as a hex string. + +Verification MUST happen before JSON parsing. Use the raw request body bytes for HMAC computation. + +```typescript +import crypto from "node:crypto"; +import type { IncomingHttpHeaders } from "node:http"; + +function verifyNeynarWebhook( + rawBody: Buffer, + headers: IncomingHttpHeaders, + webhookSecret: string +): boolean { + const signature = headers["x-neynar-signature"]; + if (typeof signature !== "string") return false; + + const hmac = crypto.createHmac("sha512", webhookSecret); + hmac.update(rawBody); + const computedSignature = hmac.digest("hex"); + + // Both must be the same length for timingSafeEqual + const sigBuffer = Buffer.from(signature, "hex"); + const computedBuffer = Buffer.from(computedSignature, "hex"); + if (sigBuffer.length !== computedBuffer.length) return false; + + return crypto.timingSafeEqual(sigBuffer, computedBuffer); +} +``` + +## Webhook Event Types + +| Event Type | Description | +|-----------|-------------| +| `cast.created` | New cast published | +| `cast.deleted` | Cast removed by author | +| `reaction.created` | Like or recast added | +| `reaction.deleted` | Like or recast removed | +| `follow.created` | User followed another user | +| `follow.deleted` | User unfollowed another user | +| `user.created` | New FID registered | +| `user.updated` | User profile updated | + +## Express Server + +```typescript +import express from "express"; +import crypto from "node:crypto"; + +const app = express(); +const WEBHOOK_SECRET = process.env.NEYNAR_WEBHOOK_SECRET; + +if (!WEBHOOK_SECRET) { + throw new Error("NEYNAR_WEBHOOK_SECRET environment variable is required"); +} + +// Raw body middleware -- ONLY on the webhook route +// Global express.json() would parse the body and break signature verification +app.use("/api/webhook", express.raw({ type: "application/json" })); + +// Use express.json() for all other routes +app.use(express.json()); + +interface WebhookEvent { + created_at: number; + type: string; + data: Record; +} + +app.post("/api/webhook", (req, res) => { + const rawBody = req.body as Buffer; + const signature = req.headers["x-neynar-signature"] as string | undefined; + + if (!signature) { + res.status(401).json({ error: "Missing X-Neynar-Signature header" }); + return; + } + + // Compute HMAC-SHA512 over raw bytes + const hmac = crypto.createHmac("sha512", WEBHOOK_SECRET); + hmac.update(rawBody); + const computed = hmac.digest("hex"); + + // Timing-safe comparison to prevent timing attacks + const sigBuffer = Buffer.from(signature, "hex"); + const computedBuffer = Buffer.from(computed, "hex"); + + if ( + sigBuffer.length !== computedBuffer.length || + !crypto.timingSafeEqual(sigBuffer, computedBuffer) + ) { + res.status(401).json({ error: "Invalid signature" }); + return; + } + + // Only parse JSON after successful signature verification + const event: WebhookEvent = JSON.parse(rawBody.toString("utf-8")); + + handleEvent(event); + + res.status(200).json({ status: "ok" }); +}); + +function handleEvent(event: WebhookEvent): void { + switch (event.type) { + case "cast.created": { + const cast = event.data as { + hash: string; + text: string; + author: { fid: number; username: string }; + }; + console.log(`New cast by @${cast.author.username}: ${cast.text}`); + break; + } + case "reaction.created": { + const reaction = event.data as { + reaction_type: string; + cast: { hash: string }; + user: { fid: number }; + }; + console.log(`Reaction ${reaction.reaction_type} on ${reaction.cast.hash}`); + break; + } + case "follow.created": { + const follow = event.data as { + user: { fid: number; username: string }; + target_user: { fid: number; username: string }; + }; + console.log(`@${follow.user.username} followed @${follow.target_user.username}`); + break; + } + default: + console.log(`Unhandled event: ${event.type}`); + } +} + +app.get("/health", (_req, res) => { + res.json({ status: "ok" }); +}); + +const PORT = process.env.PORT ?? 3001; +app.listen(PORT, () => { + console.log(`Webhook listener running on port ${PORT}`); +}); +``` + +## Register a Webhook via Neynar API + +```typescript +import { NeynarAPIClient, Configuration } from "@neynar/nodejs-sdk"; + +const config = new Configuration({ + apiKey: process.env.NEYNAR_API_KEY, +}); +const neynar = new NeynarAPIClient(config); + +async function registerWebhook() { + const webhook = await neynar.publishWebhook({ + name: "my-app-webhook", + url: "https://myapp.example.com/api/webhook", + subscription: { + "cast.created": {}, + "reaction.created": {}, + "follow.created": {}, + }, + }); + + console.log(`Webhook registered: ${webhook.webhook_id}`); + console.log(`Secret: ${webhook.secret}`); + // Store this secret as NEYNAR_WEBHOOK_SECRET +} + +registerWebhook().catch(console.error); +``` + +## Notes + +- The webhook secret is returned only once when creating the webhook -- store it securely +- Always use `express.raw()` on the webhook route, not `express.json()`, to preserve the raw bytes for HMAC verification +- Neynar retries failed webhook deliveries (non-2xx responses) with exponential backoff +- Webhook events may arrive out of order -- use the `created_at` timestamp for ordering if needed +- Rate limits on webhook subscriptions depend on your Neynar plan (Free: 1, Starter: 5, Growth: 25) +- For local development, use a tunneling service (ngrok, cloudflared) to expose your local server From 51a6a7da8bc9ba25bd768be46c9392f5cbdd9fd9 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:18:09 -0800 Subject: [PATCH 11/20] feat: add farcaster docs and resources --- skills/farcaster/docs/troubleshooting.md | 125 ++++++++++++++++ .../farcaster/resources/contract-addresses.md | 55 +++++++ skills/farcaster/resources/frame-spec.md | 134 ++++++++++++++++++ .../farcaster/resources/neynar-endpoints.md | 87 ++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 skills/farcaster/docs/troubleshooting.md create mode 100644 skills/farcaster/resources/contract-addresses.md create mode 100644 skills/farcaster/resources/frame-spec.md create mode 100644 skills/farcaster/resources/neynar-endpoints.md diff --git a/skills/farcaster/docs/troubleshooting.md b/skills/farcaster/docs/troubleshooting.md new file mode 100644 index 0000000..e261f5b --- /dev/null +++ b/skills/farcaster/docs/troubleshooting.md @@ -0,0 +1,125 @@ +# Farcaster Troubleshooting + +Common issues when building on Farcaster with Neynar API, Frames v2, and onchain registry contracts. + +## Mini App Manifest Domain Mismatch + +**Symptom:** Mini App silently fails to load in Warpcast. No error displayed. + +**Cause:** The `accountAssociation.payload` contains a domain that does not match the FQDN where `/.well-known/farcaster.json` is hosted. + +**Fix:** + +1. Decode the base64url payload: `echo 'eyJkb21haW4iOiJleGFtcGxlLmNvbSJ9' | base64 -d` +2. Verify the `domain` field matches your hosting domain exactly -- no protocol prefix, no trailing slash, no port (unless non-standard) +3. If using a subdomain (e.g., `app.example.com`), the payload must contain `app.example.com`, not `example.com` +4. Regenerate the `accountAssociation` signature if the domain changed + +## Webhook Signature Verification Fails + +**Symptom:** HMAC comparison returns false even though the webhook secret is correct. + +**Cause:** The request body was parsed (e.g., by `express.json()`) before signature verification. JSON parsing and re-serialization changes whitespace, key ordering, or encoding. + +**Fix:** + +- Use `express.raw({ type: "application/json" })` on the webhook route to get the raw `Buffer` +- Compute HMAC-SHA512 over the raw bytes, not a re-serialized JSON string +- Compare using `crypto.timingSafeEqual` with hex-encoded buffers of equal length + +```typescript +// Correct: raw body middleware on the webhook route only +app.use("/webhook", express.raw({ type: "application/json" })); + +// Wrong: global JSON parsing middleware will consume the raw body +// app.use(express.json()); // Do NOT use globally if you have webhook routes +``` + +## Frame Preview Image Not Updating + +**Symptom:** Updated image content at the same URL still shows the old image in Warpcast feeds. + +**Cause:** Warpcast aggressively caches OG images and frame preview images by URL. + +**Fix:** + +- Append a cache-busting query parameter: `https://example.com/og.png?v=2` +- Or serve images at versioned URLs: `https://example.com/og-v2.png` +- For dynamic images, include a timestamp: `https://example.com/og?t=${Date.now()}` + +## Farcaster Timestamp Off by 50 Years + +**Symptom:** Dates from Farcaster messages appear in the 1970s or 2070s. + +**Cause:** Farcaster uses a custom epoch (January 1, 2021 00:00:00 UTC), not Unix epoch. Adding the Farcaster timestamp directly to `new Date()` produces wrong results. + +**Fix:** + +```typescript +const FARCASTER_EPOCH = 1609459200; +const unixSeconds = farcasterTimestamp + FARCASTER_EPOCH; +const date = new Date(unixSeconds * 1000); +``` + +## Cast Exceeds 1024 Byte Limit + +**Symptom:** `publishCast` returns an error for text that appears under 1024 characters. + +**Cause:** The limit is 1024 bytes, not characters. UTF-8 multibyte characters (emoji = 4 bytes, CJK = 3 bytes, accented Latin = 2 bytes) consume more than 1 byte each. + +**Fix:** + +```typescript +const byteLength = Buffer.byteLength(castText, "utf-8"); +if (byteLength > 1024) { + throw new Error(`Cast is ${byteLength} bytes, max is 1024`); +} +``` + +## Neynar SDK "Unauthorized" on Valid API Key + +**Symptom:** 401 response from Neynar API despite a valid API key. + +**Cause:** The API key is passed incorrectly. The SDK v3 uses a `Configuration` object, not a constructor argument. + +**Fix:** + +```typescript +// Correct (SDK v3.x) +import { NeynarAPIClient, Configuration } from "@neynar/nodejs-sdk"; +const config = new Configuration({ apiKey: process.env.NEYNAR_API_KEY }); +const client = new NeynarAPIClient(config); + +// Wrong (old pattern) +// const client = new NeynarAPIClient(process.env.NEYNAR_API_KEY); +``` + +## App Key (Signer) Not Working + +**Symptom:** Messages signed with an Ed25519 key are rejected by Snapchain. + +**Cause:** The key was not registered in KeyRegistry on OP Mainnet for the target FID, or it was registered then removed. + +**Fix:** + +1. Verify the key is active: `cast call 0x00000000Fc1237824fb747aBDE0FF18990E59b7e "keyDataOf(uint256,bytes)(uint8,uint32)" --rpc-url https://mainnet.optimism.io` +2. State 1 = ADDED (active), State 2 = REMOVED +3. Re-register via KeyGateway if needed, or use Neynar's managed signer API + +## Transaction Frame Wallet Not Connected + +**Symptom:** `walletClient.requestAddresses()` returns empty array or throws in a Mini App. + +**Cause:** The SDK wallet provider is not available until the Mini App context is fully initialized. + +**Fix:** + +```typescript +import sdk from "@farcaster/frame-sdk"; + +const context = await sdk.context; +sdk.actions.ready(); + +// Only access wallet AFTER ready() is called +const provider = sdk.wallet.ethProvider; +``` diff --git a/skills/farcaster/resources/contract-addresses.md b/skills/farcaster/resources/contract-addresses.md new file mode 100644 index 0000000..52ff76a --- /dev/null +++ b/skills/farcaster/resources/contract-addresses.md @@ -0,0 +1,55 @@ +# Farcaster Contract Addresses + +> **Last verified:** March 2026 + +All Farcaster onchain registry contracts are deployed on OP Mainnet (Optimism, chain ID 10). + +## Registry Contracts + +| Contract | Address | Purpose | +|----------|---------|---------| +| IdRegistry | `0x00000000Fc6c5F01Fc30151999387Bb99A9f489b` | Maps FIDs to custody addresses | +| KeyRegistry | `0x00000000Fc1237824fb747aBDE0FF18990E59b7e` | Maps FIDs to Ed25519 app keys (signers) | +| StorageRegistry | `0x00000000FcCe7f938e7aE6D3c335bD6a1a7c593D` | Manages storage units per FID | + +## Gateway Contracts + +| Contract | Address | Purpose | +|----------|---------|---------| +| IdGateway | `0x00000000Fc25870C6eD6b6c7E41Fb078b7656f69` | Permissioned FID registration entry point | +| KeyGateway | `0x00000000fC56947c7E7183f8Ca4B62398CaaDF0B` | Permissioned key addition entry point | + +## Bundler + +| Contract | Address | Purpose | +|----------|---------|---------| +| Bundler | `0x00000000FC04c910A0b5feA33b03E0447ad0B0aA` | Batches register + addKey + rent in one transaction | + +## Verification + +```bash +# Verify IdRegistry deployment +cast code 0x00000000Fc6c5F01Fc30151999387Bb99A9f489b --rpc-url https://mainnet.optimism.io + +# Verify KeyRegistry deployment +cast code 0x00000000Fc1237824fb747aBDE0FF18990E59b7e --rpc-url https://mainnet.optimism.io + +# Verify StorageRegistry deployment +cast code 0x00000000FcCe7f938e7aE6D3c335bD6a1a7c593D --rpc-url https://mainnet.optimism.io + +# Lookup custody address for FID 3 +cast call 0x00000000Fc6c5F01Fc30151999387Bb99A9f489b \ + "custodyOf(uint256)(address)" 3 \ + --rpc-url https://mainnet.optimism.io + +# Check if a key is registered for an FID +cast call 0x00000000Fc1237824fb747aBDE0FF18990E59b7e \ + "keyDataOf(uint256,bytes)(uint8,uint32)" \ + --rpc-url https://mainnet.optimism.io +``` + +## Reference + +- [Farcaster Contracts (GitHub)](https://github.com/farcasterxyz/contracts) +- [IdRegistry on Optimistic Etherscan](https://optimistic.etherscan.io/address/0x00000000Fc6c5F01Fc30151999387Bb99A9f489b) +- [Farcaster Protocol Spec -- Onchain](https://github.com/farcasterxyz/protocol/blob/main/docs/SPECIFICATION.md) diff --git a/skills/farcaster/resources/frame-spec.md b/skills/farcaster/resources/frame-spec.md new file mode 100644 index 0000000..0131612 --- /dev/null +++ b/skills/farcaster/resources/frame-spec.md @@ -0,0 +1,134 @@ +# Frames v2 Specification + +> **Last verified:** March 2026 + +Frames v2 (Mini Apps) are full-screen interactive web applications embedded inside Farcaster clients. This resource covers the meta tags, manifest structure, and SDK methods. + +## Meta Tags + +Add to the `` of your HTML page. The `fc:frame` meta tag tells Farcaster clients how to render the frame preview and launch button. + +```html + +``` + +### JSON Payload Structure + +```json +{ + "version": "next", + "imageUrl": "https://example.com/og.png", + "button": { + "title": "Launch App", + "action": { + "type": "launch_frame", + "name": "My App", + "url": "https://example.com/app", + "splashImageUrl": "https://example.com/splash.png", + "splashBackgroundColor": "#1a1a2e" + } + } +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `version` | Yes | Must be `"next"` for Frames v2 | +| `imageUrl` | Yes | Preview image shown in the feed (static, no JS). Max 10MB, 3:2 aspect ratio recommended | +| `button.title` | Yes | Text on the launch button (max 32 characters) | +| `button.action.type` | Yes | Must be `"launch_frame"` | +| `button.action.name` | Yes | App name shown during launch | +| `button.action.url` | Yes | URL loaded in the Mini App iframe | +| `button.action.splashImageUrl` | No | Shown while the app loads. Square, max 200x200px | +| `button.action.splashBackgroundColor` | No | Hex color for splash screen background | + +## Manifest Structure (`/.well-known/farcaster.json`) + +```json +{ + "accountAssociation": { + "header": "", + "payload": "", + "signature": "" + }, + "frame": { + "version": "1", + "name": "App Name", + "iconUrl": "https://...", + "homeUrl": "https://...", + "splashImageUrl": "https://...", + "splashBackgroundColor": "#hex", + "webhookUrl": "https://..." + } +} +``` + +### Account Association + +The `accountAssociation` proves the FID owner controls the domain. Components: + +| Field | Content | +|-------|---------| +| `header` | Base64url-encoded JWS header: `{"fid":,"type":"custody","key":"0x"}` | +| `payload` | Base64url-encoded JSON: `{"domain":""}` | +| `signature` | Base64url-encoded Ed25519 or ECDSA signature over `header.payload` | + +The `domain` in the payload MUST exactly match the FQDN where the manifest file is hosted. No protocol prefix, no trailing slash. + +## Frame SDK Methods + +Package: `@farcaster/frame-sdk` + +### Context + +```typescript +import sdk from "@farcaster/frame-sdk"; + +const context = await sdk.context; +// context.user.fid: number +// context.user.username: string | null +// context.user.displayName: string | null +// context.user.pfpUrl: string | null +// context.client.clientFid: number +// context.client.notificationDetails: { url: string; token: string } | null +``` + +### Actions + +| Method | Description | +|--------|-------------| +| `sdk.actions.ready()` | Signal app is loaded, dismiss splash screen | +| `sdk.actions.openUrl(url)` | Open URL in client's browser | +| `sdk.actions.close()` | Close the Mini App | +| `sdk.actions.composeCast({ text, embeds })` | Open cast composer with prefilled content | +| `sdk.actions.addFrame()` | Prompt user to add app to favorites | + +### Wallet + +```typescript +const provider = sdk.wallet.ethProvider; +// EIP-1193 compatible provider +// Use with viem custom() transport or wagmi connector +``` + +## Wagmi Connector + +```bash +npm install @farcaster/frame-wagmi-connector +``` + +```typescript +import { farcasterFrame } from "@farcaster/frame-wagmi-connector"; + +const config = createConfig({ + chains: [base], + transports: { [base.id]: http() }, + connectors: [farcasterFrame()], +}); +``` + +## Reference + +- [Frames v2 Spec](https://docs.farcaster.xyz/developers/frames/v2/spec) +- [Frame SDK (npm)](https://www.npmjs.com/package/@farcaster/frame-sdk) +- [Frames Debugger](https://debugger.framesjs.org) diff --git a/skills/farcaster/resources/neynar-endpoints.md b/skills/farcaster/resources/neynar-endpoints.md new file mode 100644 index 0000000..ebc2f98 --- /dev/null +++ b/skills/farcaster/resources/neynar-endpoints.md @@ -0,0 +1,87 @@ +# Neynar API Endpoints + +> **Last verified:** March 2026 + +Key Neynar API v2 endpoints for reading and writing Farcaster data. Base URL: `https://api.neynar.com/v2/farcaster` + +All requests require the `x-api-key` header with your Neynar API key. + +## User Endpoints + +| Method | Path | Description | Credits | +|--------|------|-------------|---------| +| GET | `/user/bulk?fids=3,5` | Fetch users by FID list | 1/FID | +| GET | `/user/search?q=vitalik&limit=5` | Search users by name/username | 2 | +| GET | `/user/by_username?username=dwr.eth` | Lookup user by username | 1 | +| GET | `/user/bulk-by-address?addresses=0x...` | Lookup users by connected Ethereum address | 2/addr | +| GET | `/user/{fid}/followers?limit=25` | List followers of an FID | 3 | +| GET | `/user/{fid}/following?limit=25` | List users followed by an FID | 3 | + +## Cast Endpoints + +| Method | Path | Description | Credits | +|--------|------|-------------|---------| +| POST | `/cast` | Publish a new cast | 10 | +| DELETE | `/cast` | Delete a cast by hash | 10 | +| GET | `/cast?identifier={hash}&type=hash` | Lookup a cast by hash | 1 | +| GET | `/cast?identifier={url}&type=url` | Lookup a cast by Warpcast URL | 1 | +| GET | `/cast/conversation/{hash}` | Get cast thread / replies | 3 | + +## Feed Endpoints + +| Method | Path | Description | Credits | +|--------|------|-------------|---------| +| GET | `/feed?feed_type=following&fid=3` | Following feed for an FID | 5 | +| GET | `/feed?feed_type=filter&filter_type=channel_id&channel_id=ethereum` | Channel feed | 5 | +| GET | `/feed/trending?limit=25` | Trending casts across Farcaster | 5 | +| GET | `/feed/user/casts?fid=3&limit=25` | Casts by a specific user | 3 | + +## Reaction Endpoints + +| Method | Path | Description | Credits | +|--------|------|-------------|---------| +| POST | `/reaction` | Add a like or recast | 10 | +| DELETE | `/reaction` | Remove a like or recast | 10 | +| GET | `/reactions?target={cast_hash}&types=likes` | Get reactions on a cast | 2 | + +## Channel Endpoints + +| Method | Path | Description | Credits | +|--------|------|-------------|---------| +| GET | `/channel?id=ethereum` | Lookup channel by ID | 1 | +| GET | `/channel/search?q=defi` | Search channels | 2 | +| GET | `/channel/list?limit=50` | List all channels | 3 | +| GET | `/channel/followers?id=ethereum` | Channel followers | 3 | + +## Webhook Endpoints + +| Method | Path | Description | Credits | +|--------|------|-------------|---------| +| POST | `/webhook` | Create a webhook subscription | 50 | +| GET | `/webhook` | List active webhooks | 1 | +| PUT | `/webhook` | Update a webhook | 50 | +| DELETE | `/webhook` | Delete a webhook | 10 | + +## Signer (Managed Signer) Endpoints + +| Method | Path | Description | Credits | +|--------|------|-------------|---------| +| POST | `/signer` | Create a managed signer | 50 | +| GET | `/signer?signer_uuid={uuid}` | Get signer status | 1 | +| POST | `/signer/signed_key` | Register signed key for FID | 50 | + +## Rate Limits + +| Plan | Requests/Second | Daily Limit | +|------|-----------------|-------------| +| Free | 5 | 100K credits | +| Starter | 20 | 1M credits | +| Growth | 50 | 10M credits | +| Scale | 200 | 60M credits | + +Exceeding rate limits returns HTTP 429. Credits reset daily at midnight UTC. + +## Reference + +- [Neynar API Reference](https://docs.neynar.com/reference) +- [Neynar Node.js SDK](https://github.com/neynar/nodejs-sdk) From 9dcf32504a81aa994421472aa2ac06e9e618486e Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:33:51 -0800 Subject: [PATCH 12/20] feat: add farcaster starter template --- skills/farcaster/templates/mini-app-server.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 skills/farcaster/templates/mini-app-server.ts diff --git a/skills/farcaster/templates/mini-app-server.ts b/skills/farcaster/templates/mini-app-server.ts new file mode 100644 index 0000000..1f971ae --- /dev/null +++ b/skills/farcaster/templates/mini-app-server.ts @@ -0,0 +1,107 @@ +import express from "express"; +import crypto from "node:crypto"; +import path from "node:path"; + +const app = express(); +const WEBHOOK_SECRET = process.env.NEYNAR_WEBHOOK_SECRET; +const PORT = process.env.PORT ?? 3000; +const APP_DOMAIN = process.env.APP_DOMAIN ?? "localhost:3000"; + +if (!WEBHOOK_SECRET) { + throw new Error("NEYNAR_WEBHOOK_SECRET is required"); +} + +// Raw body on webhook route for signature verification +app.use("/api/webhook", express.raw({ type: "application/json" })); +app.use(express.json()); +app.use(express.static(path.join(__dirname, "public"))); + +// Farcaster manifest -- domain in accountAssociation.payload MUST match APP_DOMAIN +app.get("/.well-known/farcaster.json", (_req, res) => { + res.json({ + accountAssociation: { + header: process.env.FC_ASSOC_HEADER ?? "", + payload: process.env.FC_ASSOC_PAYLOAD ?? "", + signature: process.env.FC_ASSOC_SIGNATURE ?? "", + }, + frame: { + version: "1", + name: "My Mini App", + iconUrl: `https://${APP_DOMAIN}/icon.png`, + homeUrl: `https://${APP_DOMAIN}/app`, + splashImageUrl: `https://${APP_DOMAIN}/splash.png`, + splashBackgroundColor: "#1a1a2e", + webhookUrl: `https://${APP_DOMAIN}/api/webhook`, + }, + }); +}); + +app.get("/app", (_req, res) => { + res.send(` + + + + + + My Mini App + + + +

Welcome, loading...

+ +`); +}); + +// Webhook with HMAC-SHA512 verification +app.post("/api/webhook", (req, res) => { + const rawBody = req.body as Buffer; + const signature = req.headers["x-neynar-signature"]; + + if (typeof signature !== "string") { + res.status(401).json({ error: "Missing signature" }); + return; + } + + const hmac = crypto.createHmac("sha512", WEBHOOK_SECRET); + hmac.update(rawBody); + const computed = hmac.digest("hex"); + + const sigBuf = Buffer.from(signature, "hex"); + const computedBuf = Buffer.from(computed, "hex"); + + if ( + sigBuf.length !== computedBuf.length || + !crypto.timingSafeEqual(sigBuf, computedBuf) + ) { + res.status(401).json({ error: "Invalid signature" }); + return; + } + + const event = JSON.parse(rawBody.toString("utf-8")); + console.log(`Webhook event: ${event.type}`, JSON.stringify(event.data)); + + res.status(200).json({ status: "ok" }); +}); + +app.listen(PORT, () => { + console.log(`Mini App server running on port ${PORT}`); +}); From 5be3d574ec8da0efb268d6eb8bbc05aa86ac89cc Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:02:28 -0800 Subject: [PATCH 13/20] feat: add pyth-evm skill --- skills/pyth-evm/SKILL.md | 579 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 579 insertions(+) create mode 100644 skills/pyth-evm/SKILL.md diff --git a/skills/pyth-evm/SKILL.md b/skills/pyth-evm/SKILL.md new file mode 100644 index 0000000..0cee67e --- /dev/null +++ b/skills/pyth-evm/SKILL.md @@ -0,0 +1,579 @@ +--- +name: pyth-evm +description: "Pyth pull oracle integration for EVM chains. Covers price feed updates, Hermes API, confidence intervals, sponsored feeds, Express Relay for MEV-protected liquidations, and Solidity/TypeScript patterns." +license: Apache-2.0 +metadata: + author: 0xinit + version: "1.0" + chain: multichain + category: Oracles +tags: + - pyth + - oracle + - price-feeds + - pull-oracle + - hermes + - express-relay + - defi + - evm + - solidity + - confidence-interval +--- + +# Pyth EVM + +Pyth Network is a pull-based oracle that delivers high-frequency price data to EVM chains. Unlike push oracles (Chainlink) where oracle nodes update on-chain prices on a schedule, Pyth publishes prices off-chain via Hermes and consumers submit price updates on-chain when they need fresh data. This enables sub-second update latency, lower oracle costs, and prices across 500+ feeds on 70+ chains. + +## What You Probably Got Wrong + +- **Price updates can be front-run -- ALWAYS combine `updatePriceFeeds` + price consumption in a SINGLE transaction.** If you expose `updatePriceFeeds` as a standalone public function, an attacker can sandwich your price update: observe the pending update, trade before it lands, then trade after. The ONLY safe pattern is a single function that accepts `bytes[] calldata pythUpdateData`, calls `updatePriceFeeds`, and immediately reads the price. Never separate update from read. + +- **Confidence interval matters -- wide confidence = unreliable price.** Every Pyth price includes a confidence interval (1 standard deviation). A BTC price of $67,890 with confidence $680 means the true price is between $67,210 and $68,570 with ~68% probability. Reject prices where `conf > price * 0.01` (1%) for lending protocols, or your protocol becomes exploitable during high-volatility events. + +- **Update fee is dynamic -- do NOT hardcode `msg.value`.** The fee depends on the number of price updates in the `updateData`. Always call `pyth.getUpdateFee(updateData)` first and pass the exact amount as `msg.value`. Hardcoding `1 wei` will revert when the fee changes. + +- **`getPriceUnsafe()` returns arbitrarily stale data.** It is only safe to call immediately after `updatePriceFeeds` in the same transaction. In any other context, use `getPriceNoOlderThan(priceFeedId, maxAge)` which reverts if the price is too old. + +- **Pyth uses a PULL model -- prices are NOT already on-chain.** You must fetch price update data from Hermes (off-chain) and submit it on-chain. This is the opposite of Chainlink where oracles push updates on a heartbeat schedule. If your contract tries to read a Pyth price without first calling `updatePriceFeeds`, you get stale or nonexistent data. + +- **Price feed IDs are `bytes32`, NOT contract addresses.** Each feed has a unique `bytes32` identifier that is the SAME across all chains. Feed `0xff61491a...` is always ETH/USD whether you are on Ethereum, Arbitrum, or Base. The Pyth contract address varies by chain, but feed IDs do not. + +- **Pyth contract addresses VARY by chain.** Ethereum and Avalanche use `0x4305FB66699C3B2702D4d05CF36551390A4c69C6`. Arbitrum, Optimism, and Polygon use `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C`. BNB Chain uses `0x4D7E825f80bDf85e913E0DD2A2D54927e9dE1594`. Always look up the correct address for your target chain. + +- **Must call `updatePriceFeeds` BEFORE reading.** If no update has been submitted recently, `getPrice` reverts with `StalePrice` (error selector `0x19abf40e`). The update and read must happen in the same transaction for safety. + +- **Update fee is paid in native gas token (~1 wei per feed).** The fee itself is negligible. The real cost is gas: ~120K gas for a single feed update, ~150K for two feeds. + +- **Price exponents are NEGATIVE.** Pyth returns `int64 price` and `int32 expo` where `expo` is typically `-8`. A price of `6789000000000` with `expo = -8` means `6789000000000 * 10^(-8) = $67,890.00`. Always apply the exponent correctly. + +- **Sponsored feeds may eliminate the need for `updatePriceFeeds`.** On chains with Pyth-sponsored feeds (Arbitrum, Base, Ethereum mainnet), prices are pushed by sponsored updaters. Check freshness with `getPriceNoOlderThan` first -- if fresh enough, skip the update and save gas. + +## Pull Oracle Model + +Pyth's pull model inverts the traditional oracle design. Instead of oracle nodes pushing prices on-chain at fixed intervals (Chainlink's heartbeat model), Pyth publishes all prices to an off-chain service (Hermes) and consumers pull updates on-demand. + +``` +Chainlink (Push): Data Sources -> Oracle Network -> On-chain Contract -> Your Contract reads +Pyth (Pull): Data Sources -> Pyth Network -> Hermes (off-chain) -> Your dApp fetches -> Your Contract updates + reads +``` + +### Decision Matrix + +| Use Case | Recommended | Why | +|----------|-------------|-----| +| DeFi lending/borrowing | Pyth or Chainlink | Pyth for cost + freshness, Chainlink for ecosystem trust | +| DEX / perpetuals | Pyth | Sub-second latency, confidence intervals for spread calculation | +| Stablecoin peg monitoring | Chainlink | Push model ensures continuous monitoring without user action | +| Cross-chain pricing | Pyth | Same feed IDs on all chains, Hermes serves globally | +| NFT floor price | Neither | Specialized NFT oracles needed (Reservoir, custom TWAP) | +| Low-frequency reads (< 1/hr) | Chainlink | Push model avoids needing transaction infrastructure | +| High-frequency reads (> 1/min) | Pyth | Pull model lets you update only when needed, saves gas | + +## IPyth Interface + +The core interface for interacting with Pyth on EVM chains. + +```solidity +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.24; + +interface IPyth { + /// @notice Update price feeds with the given update data. + /// @dev Reverts if msg.value < getUpdateFee(updateData) + function updatePriceFeeds(bytes[] calldata updateData) external payable; + + /// @notice Get the current price for a feed. Reverts if price is stale. + function getPrice(bytes32 id) external view returns (PythStructs.Price memory); + + /// @notice Get the price without staleness check. DANGEROUS outside of atomic update+read. + function getPriceUnsafe(bytes32 id) external view returns (PythStructs.Price memory); + + /// @notice Get price only if updated within maxAge seconds. Reverts otherwise. + function getPriceNoOlderThan(bytes32 id, uint256 maxAge) + external view returns (PythStructs.Price memory); + + /// @notice Get the EMA (exponential moving average) price. + function getEmaPriceNoOlderThan(bytes32 id, uint256 maxAge) + external view returns (PythStructs.Price memory); + + /// @notice Parse price feed updates and return the results. Useful for historical/benchmark data. + /// @dev Requires msg.value >= getUpdateFee(updateData) + function parsePriceFeedUpdates( + bytes[] calldata updateData, + bytes32[] calldata priceIds, + uint64 minPublishTime, + uint64 maxPublishTime + ) external payable returns (PythStructs.PriceFeed[] memory); + + /// @notice Calculate the fee required to update the given price data. + function getUpdateFee(bytes[] calldata updateData) external view returns (uint256); +} +``` + +## PythStructs + +```solidity +library PythStructs { + struct Price { + int64 price; // Price value (apply expo to get human-readable) + uint64 conf; // Confidence interval (1 standard deviation) + int32 expo; // Price exponent (typically -8) + uint256 publishTime; // Unix timestamp of price publication + } + + struct PriceFeed { + bytes32 id; + Price price; + Price emaPrice; + } +} +``` + +### Interpreting Price Data + +| Field | Example | Meaning | +|-------|---------|---------| +| `price = 6789000000000` | -- | Raw price value | +| `expo = -8` | -- | Multiply price by 10^expo | +| Result | `6789000000000 * 10^(-8)` | `$67,890.00` | +| `conf = 68000000` | -- | Confidence: `68000000 * 10^(-8) = $0.68` | + +## Hermes API + +Hermes is Pyth's off-chain price service. Your frontend or backend fetches price update data from Hermes and submits it on-chain. + +**Base URL:** `https://hermes.pyth.network` + +### Get Latest Price Update + +``` +GET /v2/updates/price/latest?ids[]={feed_id_1}&ids[]={feed_id_2}&encoding=hex&parsed=true +``` + +Response includes `binary.data` -- an array of hex-encoded update data bytes to pass to `updatePriceFeeds`. + +### SSE Streaming + +``` +GET /v2/updates/price/stream?ids[]={feed_id}&encoding=hex&parsed=true +``` + +Returns a Server-Sent Events stream with real-time price updates. Useful for frontends that need to display live prices before submitting transactions. + +### TypeScript Client + +```typescript +import { HermesClient } from "@pythnetwork/hermes-client"; + +const hermes = new HermesClient("https://hermes.pyth.network"); + +const ETH_USD = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; + +const priceUpdates = await hermes.getLatestPriceUpdates([ETH_USD]); + +// Binary data to submit on-chain +const updateData = priceUpdates.binary.data.map( + (hex: string) => `0x${hex}` as `0x${string}` +); + +// Parsed price for display +const parsed = priceUpdates.parsed?.[0]; +if (parsed) { + const price = Number(parsed.price.price) * Math.pow(10, parsed.price.expo); + console.log(`ETH/USD: $${price.toFixed(2)}`); +} +``` + +## Price Feed IDs + +Feed IDs are `bytes32` identifiers, consistent across ALL EVM chains. + +> **Last verified:** March 2026 + +| Pair | Feed ID | +|------|---------| +| BTC/USD | `0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43` | +| ETH/USD | `0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace` | +| SOL/USD | `0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d` | +| USDC/USD | `0xeaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a` | +| USDT/USD | `0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b` | +| ARB/USD | `0x3fa4252848f9f0a1480be62745a4629d9eb1322aebab8a791e344b3b9c1adcf5` | +| MATIC/USD | `0x5de33440f6c868ee8c5fc9463ee6f6deca96e7bf3bd3e8c3e6b3b6e73e8b3b6e` | + +Full feed list: https://pyth.network/developers/price-feed-ids + +## Confidence Intervals + +Every Pyth price includes a confidence value representing 1 standard deviation. This quantifies price uncertainty -- wider confidence means less reliable pricing. + +### Validation Pattern + +```solidity +/// @notice Reject prices with confidence wider than maxConfRatio basis points +/// @dev 100 basis points = 1%. For lending, use 100 (1%). For perps, 50 (0.5%). +function _validateConfidence( + PythStructs.Price memory pythPrice, + uint256 maxConfRatioBps +) internal pure { + // conf and price share the same exponent, so ratio is dimensionless + uint256 absPrice = pythPrice.price < 0 + ? uint256(uint64(-pythPrice.price)) + : uint256(uint64(pythPrice.price)); + if (absPrice == 0) revert ZeroPrice(); + // Confidence ratio: (conf * 10000) / |price| must be <= maxConfRatioBps + if ((uint256(pythPrice.conf) * 10_000) / absPrice > maxConfRatioBps) { + revert ConfidenceTooWide(); + } +} + +error ZeroPrice(); +error ConfidenceTooWide(); +``` + +## Solidity Integration Patterns + +### Safe Price Consumer (Anti-Sandwich) + +This is the ONLY correct pattern. Update and read happen in a single function call. Never expose `updatePriceFeeds` as a standalone public function. + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; + +contract PythPriceConsumer { + IPyth public immutable pyth; + bytes32 public immutable priceFeedId; + + /// @dev 1% max confidence ratio for price validity + uint256 private constant MAX_CONF_RATIO_BPS = 100; + uint256 private constant MAX_PRICE_AGE_SECONDS = 60; + + error StalePrice(); + error NegativePrice(); + error ConfidenceTooWide(); + + constructor(address _pyth, bytes32 _priceFeedId) { + pyth = IPyth(_pyth); + priceFeedId = _priceFeedId; + } + + /// @notice Update price and return validated result in single atomic call + /// @param pythUpdateData Price update bytes from Hermes API + /// @return price The validated price (apply expo for human-readable) + /// @return expo The price exponent + /// @return publishTime The timestamp of the price + function updateAndGetPrice(bytes[] calldata pythUpdateData) + external + payable + returns (int64 price, int32 expo, uint256 publishTime) + { + uint256 updateFee = pyth.getUpdateFee(pythUpdateData); + pyth.updatePriceFeeds{value: updateFee}(pythUpdateData); + + PythStructs.Price memory pythPrice = pyth.getPriceNoOlderThan( + priceFeedId, + MAX_PRICE_AGE_SECONDS + ); + + if (pythPrice.price <= 0) revert NegativePrice(); + _validateConfidence(pythPrice); + + return (pythPrice.price, pythPrice.expo, pythPrice.publishTime); + } + + function _validateConfidence(PythStructs.Price memory pythPrice) internal pure { + uint256 absPrice = uint256(uint64(pythPrice.price)); + if ((uint256(pythPrice.conf) * 10_000) / absPrice > MAX_CONF_RATIO_BPS) { + revert ConfidenceTooWide(); + } + } +} +``` + +## TypeScript Integration + +### Dependencies + +```bash +npm install @pythnetwork/hermes-client@^3.1.0 viem +``` + +### Fetch and Submit Price Update + +```typescript +import { HermesClient } from "@pythnetwork/hermes-client"; +import { + createPublicClient, + createWalletClient, + http, + parseAbi, + type Address, +} from "viem"; +import { arbitrum } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +const PYTH_ABI = parseAbi([ + "function updatePriceFeeds(bytes[] calldata updateData) external payable", + "function getUpdateFee(bytes[] calldata updateData) external view returns (uint256)", + "function getPriceNoOlderThan(bytes32 id, uint256 age) external view returns (tuple(int64 price, uint64 conf, int32 expo, uint256 publishTime))", +]); + +const ETH_USD_FEED = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" as `0x${string}`; +const PYTH_ADDRESS = "0xff1a0f4744e8582DF1aE09D5611b887B6a12925C" as Address; + +const hermes = new HermesClient("https://hermes.pyth.network"); + +const publicClient = createPublicClient({ + chain: arbitrum, + transport: http(process.env.RPC_URL), +}); + +const walletClient = createWalletClient({ + account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`), + chain: arbitrum, + transport: http(process.env.RPC_URL), +}); + +async function updateAndReadPrice(): Promise<{ + price: number; + confidence: number; +}> { + const priceUpdates = await hermes.getLatestPriceUpdates([ETH_USD_FEED]); + + const updateData = priceUpdates.binary.data.map( + (hex: string) => `0x${hex}` as `0x${string}` + ); + + const updateFee = await publicClient.readContract({ + address: PYTH_ADDRESS, + abi: PYTH_ABI, + functionName: "getUpdateFee", + args: [updateData], + }); + + const hash = await walletClient.writeContract({ + address: PYTH_ADDRESS, + abi: PYTH_ABI, + functionName: "updatePriceFeeds", + args: [updateData], + value: updateFee, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status === "reverted") { + throw new Error("Price update transaction reverted"); + } + + const pythPrice = await publicClient.readContract({ + address: PYTH_ADDRESS, + abi: PYTH_ABI, + functionName: "getPriceNoOlderThan", + args: [ETH_USD_FEED, 60n], + }); + + const price = Number(pythPrice.price) * Math.pow(10, pythPrice.expo); + const confidence = Number(pythPrice.conf) * Math.pow(10, pythPrice.expo); + + return { price, confidence }; +} +``` + +## Contract Addresses + +> **Last verified:** March 2026. Addresses differ per chain -- always verify before deployment. + +| Chain | Pyth Contract Address | +|-------|-----------------------| +| Ethereum | `0x4305FB66699C3B2702D4d05CF36551390A4c69C6` | +| Arbitrum | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | +| Optimism | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | +| Base | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | +| Polygon | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | +| Avalanche | `0x4305FB66699C3B2702D4d05CF36551390A4c69C6` | +| BNB Chain | `0x4D7E825f80bDf85e913E0DD2A2D54927e9dE1594` | +| Gnosis | `0x2880aB155794e7179c9eE2e38200202908C17B43` | +| Fantom | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | + +```bash +# Verify Pyth deployment on any chain +cast code 0xff1a0f4744e8582DF1aE09D5611b887B6a12925C --rpc-url $RPC_URL +``` + +## Gas Costs + +> **Last verified:** March 2026 + +| Operation | Gas (approx) | Notes | +|-----------|-------------|-------| +| `updatePriceFeeds` (1 feed) | ~120,000 | First update in a block costs more | +| `updatePriceFeeds` (2 feeds) | ~150,000 | Marginal cost per additional feed ~30K | +| `updatePriceFeeds` (5 feeds) | ~240,000 | Batch updates are efficient | +| `getPriceUnsafe` (read) | ~2,500 | View call, no state change | +| `getPriceNoOlderThan` (read) | ~3,000 | View call with staleness check | +| `parsePriceFeedUpdates` (1 feed) | ~130,000 | Slightly more than updatePriceFeeds | + +## Sponsored Feeds + +On select chains, Pyth sponsors price feed updates through dedicated updater services. These chains have reasonably fresh prices available without users needing to call `updatePriceFeeds`. + +### Chains with Sponsored Feeds + +- **Ethereum mainnet** -- major pairs (BTC, ETH, stablecoins) +- **Arbitrum** -- broad feed coverage +- **Base** -- major pairs + +### Using Sponsored Feeds + +```solidity +function getPriceWithFallback(bytes[] calldata pythUpdateData) + external payable returns (int64 price, int32 expo) +{ + // Attempt to read sponsored (already-fresh) price + try pyth.getPriceNoOlderThan(priceFeedId, 60) returns ( + PythStructs.Price memory freshPrice + ) { + if (freshPrice.price <= 0) revert NegativePrice(); + return (freshPrice.price, freshPrice.expo); + } catch { + // Fall back to user-submitted update + uint256 fee = pyth.getUpdateFee(pythUpdateData); + pyth.updatePriceFeeds{value: fee}(pythUpdateData); + + PythStructs.Price memory updatedPrice = pyth.getPriceNoOlderThan( + priceFeedId, 60 + ); + if (updatedPrice.price <= 0) revert NegativePrice(); + return (updatedPrice.price, updatedPrice.expo); + } +} +``` + +## Benchmarks + +Pyth supports historical price queries via `parsePriceFeedUpdates`. This is useful for settlement, TWAP calculations, and dispute resolution. + +```solidity +function getHistoricalPrice( + bytes[] calldata updateData, + bytes32 feedId, + uint64 targetTimestamp +) external payable returns (PythStructs.Price memory) { + bytes32[] memory ids = new bytes32[](1); + ids[0] = feedId; + + uint256 fee = pyth.getUpdateFee(updateData); + + // Window: targetTimestamp +/- 30 seconds + PythStructs.PriceFeed[] memory feeds = pyth.parsePriceFeedUpdates{value: fee}( + updateData, + ids, + targetTimestamp - 30, + targetTimestamp + 30 + ); + + return feeds[0].price; +} +``` + +### Fetching Historical Data from Hermes + +``` +GET /v2/updates/price/{publishTime}?ids[]={feed_id}&encoding=hex&parsed=true +``` + +The `publishTime` parameter is a Unix timestamp. Hermes returns the closest price update to that timestamp. + +## Express Relay + +Express Relay is Pyth's MEV protection layer for DeFi liquidations. Instead of exposing liquidation opportunities to the public mempool (where MEV bots extract value via sandwich attacks and priority gas auctions), Express Relay routes liquidation opportunities through a sealed-bid auction. + +### How It Works + +1. Protocol registers liquidation conditions with Express Relay +2. When a position becomes liquidatable, Express Relay runs a sealed-bid auction among searchers +3. Winning searcher executes the liquidation +4. Auction proceeds go to the protocol (not to MEV bots) + +### Supported Chains (11+) + +Ethereum, Arbitrum, Optimism, Base, Polygon, Avalanche, BNB Chain, Monad (testnet), Sei, Blast, Mode + +### Integration + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@pythnetwork/express-relay-sdk-solidity/IExpressRelayFeeReceiver.sol"; + +contract LendingProtocol is IExpressRelayFeeReceiver { + address public immutable expressRelay; + + error CallerNotExpressRelay(); + + constructor(address _expressRelay) { + expressRelay = _expressRelay; + } + + /// @notice Called by Express Relay to distribute auction proceeds + function receiveAuctionProceedings() external payable { + if (msg.sender != expressRelay) revert CallerNotExpressRelay(); + // Auction proceeds received -- credit to protocol treasury + } + + function liquidate( + address borrower, + bytes[] calldata pythUpdateData + ) external payable { + // Only Express Relay can call this, or allow public liquidations + // with MEV protection via the auction mechanism + + // Update prices atomically + uint256 fee = IPyth(pyth).getUpdateFee(pythUpdateData); + IPyth(pyth).updatePriceFeeds{value: fee}(pythUpdateData); + + // Check if position is liquidatable using fresh prices + // ... liquidation logic ... + } +} +``` + +## Pyth Lazer + +Pyth Lazer is a separate product optimized for ultra-low-latency applications (HFT, perpetual DEXes). It delivers prices with ~1ms latency via WebSocket, compared to ~400ms for standard Pyth/Hermes. + +- Separate subscription required +- Different data format (not compatible with standard Pyth SDK) +- Currently supports select feeds on select chains +- Documentation: https://docs.pyth.network/lazer + +## Recommended Function Usage + +| Use Case | Function | Why | +|----------|----------|-----| +| Standard price read | `getPriceNoOlderThan(id, maxAge)` | Reverts if stale, configurable freshness | +| Gas-optimized read (after update in same tx) | `getPriceUnsafe(id)` | Cheapest read, safe only after atomic update | +| Historical / settlement | `parsePriceFeedUpdates(data, ids, minTime, maxTime)` | Returns price at specific timestamp | +| Liquidation threshold | `getEmaPriceNoOlderThan(id, maxAge)` | EMA smooths volatility spikes, reduces false liquidations | +| Sponsored chain check | `getPriceNoOlderThan(id, 60)` in try/catch | Skip update if feed is already fresh | + +## Related Skills + +- **chainlink** -- Push-based oracle, complementary to Pyth for multi-oracle fallback strategies +- **redstone** -- Another pull oracle with modular design, ERC-7412 compatible +- **pyth** -- Pyth on Solana (native, non-EVM integration) + +## References + +- [Pyth Network Documentation](https://docs.pyth.network) +- [Pyth EVM SDK (pyth-sdk-solidity)](https://github.com/pyth-network/pyth-crosschain/tree/main/target_chains/ethereum/sdk/solidity) +- [Hermes API Reference](https://hermes.pyth.network/docs) +- [Price Feed IDs](https://pyth.network/developers/price-feed-ids) +- [Express Relay Documentation](https://docs.pyth.network/express-relay) +- [Pyth Lazer Documentation](https://docs.pyth.network/lazer) +- [Pyth Contract Addresses](https://docs.pyth.network/price-feeds/contract-addresses/evm) +- [@pythnetwork/hermes-client npm](https://www.npmjs.com/package/@pythnetwork/hermes-client) +- [Pyth Best Practices](https://docs.pyth.network/price-feeds/best-practices) From f2d1598c887a8d463195e2391962d749066ede63 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:38:16 -0800 Subject: [PATCH 14/20] feat: add pyth-evm examples --- .../examples/express-relay-basic/README.md | 185 ++++++++++++++++ .../pyth-evm/examples/hermes-client/README.md | 162 ++++++++++++++ .../examples/multi-feed-update/README.md | 207 ++++++++++++++++++ .../examples/price-feed-solidity/README.md | 178 +++++++++++++++ 4 files changed, 732 insertions(+) create mode 100644 skills/pyth-evm/examples/express-relay-basic/README.md create mode 100644 skills/pyth-evm/examples/hermes-client/README.md create mode 100644 skills/pyth-evm/examples/multi-feed-update/README.md create mode 100644 skills/pyth-evm/examples/price-feed-solidity/README.md diff --git a/skills/pyth-evm/examples/express-relay-basic/README.md b/skills/pyth-evm/examples/express-relay-basic/README.md new file mode 100644 index 0000000..9925681 --- /dev/null +++ b/skills/pyth-evm/examples/express-relay-basic/README.md @@ -0,0 +1,185 @@ +# Express Relay Integration (MEV-Protected Liquidations) + +Basic Express Relay integration for a lending protocol. Express Relay routes liquidation opportunities through sealed-bid auctions instead of the public mempool, capturing MEV value for the protocol instead of losing it to searchers. + +## Dependencies + +```bash +forge install pyth-network/pyth-crosschain +npm install @pythnetwork/express-relay-js@^0.10.0 +``` + +## Solidity Contract + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; + +/// @notice Lending protocol with Express Relay MEV protection +/// @dev Express Relay calls liquidate() via sealed auction -- proceeds go to protocol +contract ExpressRelayLending { + IPyth public immutable pyth; + address public immutable expressRelay; + + bytes32 public immutable collateralFeedId; + bytes32 public immutable debtFeedId; + + /// @dev 120% collateral ratio threshold for liquidation + uint256 private constant LIQUIDATION_THRESHOLD_BPS = 12_000; + uint256 private constant MAX_PRICE_AGE = 60; + uint256 private constant MAX_CONF_RATIO_BPS = 100; + + struct Position { + uint256 collateralAmount; + uint256 debtAmount; + } + + mapping(address => Position) public positions; + + error NotLiquidatable(); + error NegativePrice(); + error ConfidenceTooWide(); + error CallerNotExpressRelay(); + + event PositionLiquidated(address indexed borrower, address indexed liquidator); + event AuctionProceedsReceived(uint256 amount); + + constructor( + address _pyth, + address _expressRelay, + bytes32 _collateralFeedId, + bytes32 _debtFeedId + ) { + pyth = IPyth(_pyth); + expressRelay = _expressRelay; + collateralFeedId = _collateralFeedId; + debtFeedId = _debtFeedId; + } + + /// @notice Called by Express Relay to deliver auction proceeds + receive() external payable { + if (msg.sender != expressRelay) revert CallerNotExpressRelay(); + emit AuctionProceedsReceived(msg.value); + } + + /// @notice Liquidate an undercollateralized position + /// @dev Express Relay routes this via sealed-bid auction for MEV protection + /// @param borrower Address of the position to liquidate + /// @param pythUpdateData Fresh price data from Hermes + function liquidate( + address borrower, + bytes[] calldata pythUpdateData + ) external payable { + // Atomic price update -- never separate from liquidation logic + uint256 fee = pyth.getUpdateFee(pythUpdateData); + pyth.updatePriceFeeds{value: fee}(pythUpdateData); + + PythStructs.Price memory collateralPrice = pyth.getPriceNoOlderThan( + collateralFeedId, MAX_PRICE_AGE + ); + PythStructs.Price memory debtPrice = pyth.getPriceNoOlderThan( + debtFeedId, MAX_PRICE_AGE + ); + + if (collateralPrice.price <= 0 || debtPrice.price <= 0) revert NegativePrice(); + _validateConfidence(collateralPrice); + _validateConfidence(debtPrice); + + Position storage pos = positions[borrower]; + + // Collateral value in debt terms (both prices share same expo = -8) + uint256 collateralValue = pos.collateralAmount * uint256(uint64(collateralPrice.price)); + uint256 debtValue = pos.debtAmount * uint256(uint64(debtPrice.price)); + + // Position must be below liquidation threshold + if (collateralValue * 10_000 >= debtValue * LIQUIDATION_THRESHOLD_BPS) { + revert NotLiquidatable(); + } + + // Execute liquidation -- transfer collateral to liquidator, clear debt + delete positions[borrower]; + emit PositionLiquidated(borrower, msg.sender); + + // Refund excess ETH + uint256 excess = msg.value - fee; + if (excess > 0) { + (bool ok, ) = msg.sender.call{value: excess}(""); + require(ok); + } + } + + function _validateConfidence(PythStructs.Price memory pythPrice) internal pure { + uint256 absPrice = uint256(uint64(pythPrice.price)); + if ((uint256(pythPrice.conf) * 10_000) / absPrice > MAX_CONF_RATIO_BPS) { + revert ConfidenceTooWide(); + } + } +} +``` + +## Searcher Integration (TypeScript) + +Searchers submit bids to Express Relay for the right to execute liquidations. + +```typescript +import { Client as ExpressRelayClient } from "@pythnetwork/express-relay-js"; +import { HermesClient } from "@pythnetwork/hermes-client"; +import { + createPublicClient, + http, + encodeFunctionData, + parseAbi, + type Address, +} from "viem"; +import { arbitrum } from "viem/chains"; + +const LENDING_ABI = parseAbi([ + "function liquidate(address borrower, bytes[] calldata pythUpdateData) external payable", +]); + +const LENDING_ADDRESS = "0xYourLendingContract" as Address; +const PYTH_ADDRESS = "0xff1a0f4744e8582DF1aE09D5611b887B6a12925C" as Address; +const ETH_USD = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; + +const hermes = new HermesClient("https://hermes.pyth.network"); + +async function submitLiquidationBid(borrower: Address) { + const client = new ExpressRelayClient({ + baseUrl: "https://per-arbitrum.dourolabs.app", + }); + + const updates = await hermes.getLatestPriceUpdates([ETH_USD]); + const updateData = updates.binary.data.map( + (hex: string) => `0x${hex}` as `0x${string}` + ); + + const calldata = encodeFunctionData({ + abi: LENDING_ABI, + functionName: "liquidate", + args: [borrower, updateData], + }); + + const bid = await client.submitBid({ + chainId: "42161", + targetContract: LENDING_ADDRESS, + targetCalldata: calldata, + targetCallValue: 1n, + permissionKey: borrower, + amount: 100000000000000n, // Bid amount in wei (0.0001 ETH) + }); + + console.log(`Bid submitted: ${bid.id}`); + return bid; +} +``` + +## Notes + +- Express Relay runs sealed-bid auctions -- searchers compete on bid amount, not gas priority. The winning bid amount goes to the protocol, not to block builders. +- The `receive()` function is how the protocol receives auction proceeds. Only the Express Relay contract should be allowed to call it. +- Supported on 11+ chains: Ethereum, Arbitrum, Optimism, Base, Polygon, Avalanche, BNB Chain, Monad (testnet), Sei, Blast, Mode. +- Price updates are still atomic with the liquidation check -- the anti-sandwich pattern applies even within Express Relay flows. +- For production, implement proper position tracking, collateral transfer logic, and access controls beyond what this minimal example shows. diff --git a/skills/pyth-evm/examples/hermes-client/README.md b/skills/pyth-evm/examples/hermes-client/README.md new file mode 100644 index 0000000..f0c686e --- /dev/null +++ b/skills/pyth-evm/examples/hermes-client/README.md @@ -0,0 +1,162 @@ +# Hermes Client (TypeScript) + +Fetch Pyth price data from Hermes API using `@pythnetwork/hermes-client` and submit on-chain via viem. + +## Dependencies + +```bash +npm install @pythnetwork/hermes-client@^3.1.0 viem +``` + +## Fetch Latest Price (Off-Chain Only) + +```typescript +import { HermesClient } from "@pythnetwork/hermes-client"; + +const hermes = new HermesClient("https://hermes.pyth.network"); + +const BTC_USD = "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"; +const ETH_USD = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; +const SOL_USD = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; + +async function getLatestPrices() { + const updates = await hermes.getLatestPriceUpdates([BTC_USD, ETH_USD, SOL_USD]); + + if (!updates.parsed || updates.parsed.length === 0) { + throw new Error("No parsed price data returned from Hermes"); + } + + for (const feed of updates.parsed) { + const price = Number(feed.price.price) * Math.pow(10, feed.price.expo); + const conf = Number(feed.price.conf) * Math.pow(10, feed.price.expo); + const confPct = (conf / price) * 100; + + console.log(`Feed: ${feed.id}`); + console.log(` Price: $${price.toFixed(2)}`); + console.log(` Confidence: +/- $${conf.toFixed(2)} (${confPct.toFixed(3)}%)`); + console.log(` Published: ${new Date(Number(feed.price.publish_time) * 1000).toISOString()}`); + } + + return updates; +} +``` + +## Submit Price Update On-Chain + +```typescript +import { + createPublicClient, + createWalletClient, + http, + parseAbi, + type Address, +} from "viem"; +import { arbitrum } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; +import { HermesClient } from "@pythnetwork/hermes-client"; + +const PYTH_ABI = parseAbi([ + "function updatePriceFeeds(bytes[] calldata updateData) external payable", + "function getUpdateFee(bytes[] calldata updateData) external view returns (uint256)", + "function getPriceNoOlderThan(bytes32 id, uint256 age) external view returns (tuple(int64 price, uint64 conf, int32 expo, uint256 publishTime))", +]); + +// Arbitrum Pyth address +const PYTH_ADDRESS = "0xff1a0f4744e8582DF1aE09D5611b887B6a12925C" as Address; +const ETH_USD = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" as `0x${string}`; + +const publicClient = createPublicClient({ + chain: arbitrum, + transport: http(process.env.RPC_URL), +}); + +const walletClient = createWalletClient({ + account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`), + chain: arbitrum, + transport: http(process.env.RPC_URL), +}); + +const hermes = new HermesClient("https://hermes.pyth.network"); + +async function submitPriceUpdate() { + const priceUpdates = await hermes.getLatestPriceUpdates([ETH_USD]); + + const updateData = priceUpdates.binary.data.map( + (hex: string) => `0x${hex}` as `0x${string}` + ); + + // Compute fee dynamically -- never hardcode + const updateFee = await publicClient.readContract({ + address: PYTH_ADDRESS, + abi: PYTH_ABI, + functionName: "getUpdateFee", + args: [updateData], + }); + + const hash = await walletClient.writeContract({ + address: PYTH_ADDRESS, + abi: PYTH_ABI, + functionName: "updatePriceFeeds", + args: [updateData], + value: updateFee, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status === "reverted") { + throw new Error("Price update transaction reverted"); + } + + console.log(`Price updated: ${hash}`); + + // Read the updated price + const pythPrice = await publicClient.readContract({ + address: PYTH_ADDRESS, + abi: PYTH_ABI, + functionName: "getPriceNoOlderThan", + args: [ETH_USD, 60n], + }); + + const price = Number(pythPrice.price) * Math.pow(10, pythPrice.expo); + console.log(`ETH/USD: $${price.toFixed(2)}`); + + return { hash, price }; +} +``` + +## SSE Streaming (Real-Time Prices) + +```typescript +import { HermesClient } from "@pythnetwork/hermes-client"; + +const hermes = new HermesClient("https://hermes.pyth.network"); +const ETH_USD = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; + +async function streamPrices() { + const eventSource = await hermes.getPriceUpdatesStream([ETH_USD], { + encoding: "hex", + parsed: true, + }); + + eventSource.onmessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + if (data.parsed && data.parsed.length > 0) { + const feed = data.parsed[0]; + const price = Number(feed.price.price) * Math.pow(10, feed.price.expo); + console.log(`ETH/USD: $${price.toFixed(2)} @ ${new Date().toISOString()}`); + } + }; + + eventSource.onerror = (error: Event) => { + console.error("SSE stream error:", error); + eventSource.close(); + }; +} +``` + +## Notes + +- `@pythnetwork/hermes-client` v3.1.0 is the current stable release. It provides type-safe access to Hermes REST and SSE endpoints. +- Feed IDs are the same `bytes32` across all chains. You do not need chain-specific feed IDs. +- Hermes returns `binary.data` as hex strings without the `0x` prefix. Prepend `0x` before passing to contract calls. +- For production workloads, consider running your own Hermes instance to avoid rate limits. +- The SSE stream is useful for frontends that need live price display. The binary data from stream events can be submitted on-chain. diff --git a/skills/pyth-evm/examples/multi-feed-update/README.md b/skills/pyth-evm/examples/multi-feed-update/README.md new file mode 100644 index 0000000..8a363b0 --- /dev/null +++ b/skills/pyth-evm/examples/multi-feed-update/README.md @@ -0,0 +1,207 @@ +# Multi-Feed Price Update + +Update and read multiple Pyth price feeds in a single transaction. Useful for protocols that need correlated prices (e.g., collateral/debt pairs in lending, multi-asset portfolio valuations). + +## Dependencies + +```bash +forge install pyth-network/pyth-crosschain +``` + +## Solidity Contract + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; + +/// @notice Read multiple Pyth prices atomically in a single transaction +contract MultiPriceReader { + IPyth public immutable pyth; + + uint256 private constant MAX_PRICE_AGE = 60; + uint256 private constant MAX_CONF_RATIO_BPS = 100; + + struct PriceResult { + bytes32 feedId; + int64 price; + uint64 conf; + int32 expo; + uint256 publishTime; + } + + error NegativePrice(bytes32 feedId); + error ConfidenceTooWide(bytes32 feedId); + error InsufficientPayment(); + + constructor(address _pyth) { + pyth = IPyth(_pyth); + } + + /// @notice Update all feeds and return validated prices atomically + /// @param feedIds Array of Pyth feed IDs to read + /// @param pythUpdateData Price update bytes from Hermes (may contain multiple feeds) + function updateAndGetPrices( + bytes32[] calldata feedIds, + bytes[] calldata pythUpdateData + ) external payable returns (PriceResult[] memory results) { + uint256 updateFee = pyth.getUpdateFee(pythUpdateData); + if (msg.value < updateFee) revert InsufficientPayment(); + + pyth.updatePriceFeeds{value: updateFee}(pythUpdateData); + + results = new PriceResult[](feedIds.length); + + for (uint256 i = 0; i < feedIds.length; i++) { + PythStructs.Price memory pythPrice = pyth.getPriceNoOlderThan( + feedIds[i], + MAX_PRICE_AGE + ); + + if (pythPrice.price <= 0) revert NegativePrice(feedIds[i]); + _validateConfidence(pythPrice, feedIds[i]); + + results[i] = PriceResult({ + feedId: feedIds[i], + price: pythPrice.price, + conf: pythPrice.conf, + expo: pythPrice.expo, + publishTime: pythPrice.publishTime + }); + } + + // Refund excess + uint256 excess = msg.value - updateFee; + if (excess > 0) { + (bool ok, ) = msg.sender.call{value: excess}(""); + require(ok); + } + } + + /// @notice Compute a price ratio (e.g., ETH/BTC from ETH/USD and BTC/USD) + /// @dev Both feeds must have the same exponent for this to work correctly + function updateAndGetRatio( + bytes32 numeratorFeedId, + bytes32 denominatorFeedId, + bytes[] calldata pythUpdateData + ) external payable returns (uint256 ratioBps) { + uint256 updateFee = pyth.getUpdateFee(pythUpdateData); + if (msg.value < updateFee) revert InsufficientPayment(); + + pyth.updatePriceFeeds{value: updateFee}(pythUpdateData); + + PythStructs.Price memory numPrice = pyth.getPriceNoOlderThan( + numeratorFeedId, MAX_PRICE_AGE + ); + PythStructs.Price memory denomPrice = pyth.getPriceNoOlderThan( + denominatorFeedId, MAX_PRICE_AGE + ); + + if (numPrice.price <= 0) revert NegativePrice(numeratorFeedId); + if (denomPrice.price <= 0) revert NegativePrice(denominatorFeedId); + _validateConfidence(numPrice, numeratorFeedId); + _validateConfidence(denomPrice, denominatorFeedId); + + // Ratio in basis points (both prices share expo, so it cancels) + ratioBps = (uint256(uint64(numPrice.price)) * 10_000) / + uint256(uint64(denomPrice.price)); + + uint256 excess = msg.value - updateFee; + if (excess > 0) { + (bool ok, ) = msg.sender.call{value: excess}(""); + require(ok); + } + } + + function _validateConfidence( + PythStructs.Price memory pythPrice, + bytes32 feedId + ) internal pure { + uint256 absPrice = uint256(uint64(pythPrice.price)); + if ((uint256(pythPrice.conf) * 10_000) / absPrice > MAX_CONF_RATIO_BPS) { + revert ConfidenceTooWide(feedId); + } + } +} +``` + +## TypeScript Usage + +```typescript +import { HermesClient } from "@pythnetwork/hermes-client"; +import { + createPublicClient, + createWalletClient, + http, + parseAbi, + type Address, +} from "viem"; +import { arbitrum } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +const BTC_USD = "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"; +const ETH_USD = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; +const SOL_USD = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; + +const PYTH_ADDRESS = "0xff1a0f4744e8582DF1aE09D5611b887B6a12925C" as Address; +const READER_ADDRESS = "0xYourMultiPriceReader" as Address; + +const READER_ABI = parseAbi([ + "function updateAndGetPrices(bytes32[] calldata feedIds, bytes[] calldata pythUpdateData) external payable returns (tuple(bytes32 feedId, int64 price, uint64 conf, int32 expo, uint256 publishTime)[])", +]); + +const hermes = new HermesClient("https://hermes.pyth.network"); + +const publicClient = createPublicClient({ + chain: arbitrum, + transport: http(process.env.RPC_URL), +}); + +const walletClient = createWalletClient({ + account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`), + chain: arbitrum, + transport: http(process.env.RPC_URL), +}); + +async function getMultiplePrices() { + const feedIds = [BTC_USD, ETH_USD, SOL_USD] as `0x${string}`[]; + + // Fetch all feeds in a single Hermes request + const updates = await hermes.getLatestPriceUpdates(feedIds); + const updateData = updates.binary.data.map( + (hex: string) => `0x${hex}` as `0x${string}` + ); + + // Compute update fee + const updateFee = await publicClient.readContract({ + address: PYTH_ADDRESS, + abi: parseAbi(["function getUpdateFee(bytes[] calldata) view returns (uint256)"]), + functionName: "getUpdateFee", + args: [updateData], + }); + + const hash = await walletClient.writeContract({ + address: READER_ADDRESS, + abi: READER_ABI, + functionName: "updateAndGetPrices", + args: [feedIds, updateData], + value: updateFee, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status === "reverted") { + throw new Error("Multi-feed update reverted"); + } + + console.log(`Updated ${feedIds.length} feeds in tx: ${hash}`); +} +``` + +## Notes + +- Hermes accepts multiple feed IDs in a single request. The response bundles all updates into one `binary.data` array that gets passed to `updatePriceFeeds`. +- Gas cost scales sub-linearly: 1 feed = ~120K gas, 2 feeds = ~150K, 5 feeds = ~240K. Batching is significantly cheaper than individual updates. +- When computing ratios between feeds, both prices must use the same exponent for the ratio to be valid. Standard USD pairs all use `expo = -8`. +- The `updateAndGetRatio` function is useful for computing cross-rates (e.g., ETH/BTC) without needing a dedicated oracle feed for that pair. diff --git a/skills/pyth-evm/examples/price-feed-solidity/README.md b/skills/pyth-evm/examples/price-feed-solidity/README.md new file mode 100644 index 0000000..3af7c92 --- /dev/null +++ b/skills/pyth-evm/examples/price-feed-solidity/README.md @@ -0,0 +1,178 @@ +# Pyth Price Feed Consumer (Solidity) + +Complete Solidity contract that consumes Pyth price data with the anti-sandwich pattern: update and read in a single atomic function call. Includes confidence interval validation and dynamic fee computation. + +## Dependencies + +```bash +forge install pyth-network/pyth-crosschain +``` + +Add to `remappings.txt`: +``` +@pythnetwork/pyth-sdk-solidity/=lib/pyth-crosschain/target_chains/ethereum/sdk/solidity/ +``` + +## Contract + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; + +/// @notice Consumes Pyth price feeds with atomic update+read pattern +/// @dev NEVER expose updatePriceFeeds as a standalone public function +contract PythPriceConsumer { + IPyth public immutable pyth; + + /// @dev 1% max confidence-to-price ratio (100 basis points) + uint256 private constant MAX_CONF_RATIO_BPS = 100; + uint256 private constant MAX_PRICE_AGE = 60; + + error NegativePrice(); + error ConfidenceTooWide(); + error InsufficientPayment(); + + event PriceUpdated(bytes32 indexed feedId, int64 price, uint64 conf, int32 expo); + + constructor(address _pyth) { + pyth = IPyth(_pyth); + } + + /// @notice Atomically update price feed and return validated price + /// @param feedId The Pyth price feed ID (bytes32, same across all chains) + /// @param pythUpdateData Price update bytes fetched from Hermes API + /// @return price Raw price value (apply expo to get human-readable) + /// @return conf Confidence interval (1 standard deviation) + /// @return expo Price exponent (typically -8) + /// @return publishTime Unix timestamp of price publication + function updateAndGetPrice( + bytes32 feedId, + bytes[] calldata pythUpdateData + ) + external + payable + returns (int64 price, uint64 conf, int32 expo, uint256 publishTime) + { + // Compute required fee -- never hardcode + uint256 updateFee = pyth.getUpdateFee(pythUpdateData); + if (msg.value < updateFee) revert InsufficientPayment(); + + // Update price feed on-chain + pyth.updatePriceFeeds{value: updateFee}(pythUpdateData); + + // Read price immediately (same tx = safe from front-running) + PythStructs.Price memory pythPrice = pyth.getPriceNoOlderThan( + feedId, + MAX_PRICE_AGE + ); + + // Validate price is positive + if (pythPrice.price <= 0) revert NegativePrice(); + + // Validate confidence interval is tight enough + _validateConfidence(pythPrice); + + emit PriceUpdated(feedId, pythPrice.price, pythPrice.conf, pythPrice.expo); + + // Refund excess ETH + uint256 excess = msg.value - updateFee; + if (excess > 0) { + (bool ok, ) = msg.sender.call{value: excess}(""); + require(ok); + } + + return (pythPrice.price, pythPrice.conf, pythPrice.expo, pythPrice.publishTime); + } + + /// @notice Read price without update (only safe on sponsored-feed chains) + /// @dev Reverts with StalePrice if no recent update exists + function getExistingPrice(bytes32 feedId) + external + view + returns (int64 price, int32 expo) + { + PythStructs.Price memory pythPrice = pyth.getPriceNoOlderThan( + feedId, + MAX_PRICE_AGE + ); + if (pythPrice.price <= 0) revert NegativePrice(); + _validateConfidence(pythPrice); + return (pythPrice.price, pythPrice.expo); + } + + function _validateConfidence(PythStructs.Price memory pythPrice) internal pure { + uint256 absPrice = uint256(uint64(pythPrice.price)); + if ((uint256(pythPrice.conf) * 10_000) / absPrice > MAX_CONF_RATIO_BPS) { + revert ConfidenceTooWide(); + } + } +} +``` + +## Deployment + +```bash +# Arbitrum deployment +forge create src/PythPriceConsumer.sol:PythPriceConsumer \ + --rpc-url $ARBITRUM_RPC \ + --private-key $PRIVATE_KEY \ + --constructor-args 0xff1a0f4744e8582DF1aE09D5611b887B6a12925C \ + --verify +``` + +## Usage (TypeScript) + +```typescript +import { HermesClient } from "@pythnetwork/hermes-client"; +import { createPublicClient, createWalletClient, http, parseAbi, type Address } from "viem"; +import { arbitrum } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +const CONSUMER_ABI = parseAbi([ + "function updateAndGetPrice(bytes32 feedId, bytes[] calldata pythUpdateData) external payable returns (int64, uint64, int32, uint256)", +]); + +const ETH_USD = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" as `0x${string}`; +const CONSUMER_ADDRESS = "0xYourDeployedConsumer" as Address; +const PYTH_ADDRESS = "0xff1a0f4744e8582DF1aE09D5611b887B6a12925C" as Address; + +const hermes = new HermesClient("https://hermes.pyth.network"); + +async function getPrice() { + const updates = await hermes.getLatestPriceUpdates([ETH_USD]); + const updateData = updates.binary.data.map( + (hex: string) => `0x${hex}` as `0x${string}` + ); + + // Compute fee via Pyth contract + const fee = await publicClient.readContract({ + address: PYTH_ADDRESS, + abi: parseAbi(["function getUpdateFee(bytes[] calldata) view returns (uint256)"]), + functionName: "getUpdateFee", + args: [updateData], + }); + + const hash = await walletClient.writeContract({ + address: CONSUMER_ADDRESS, + abi: CONSUMER_ABI, + functionName: "updateAndGetPrice", + args: [ETH_USD, updateData], + value: fee, + }); + + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status === "reverted") { + throw new Error("Transaction reverted"); + } +} +``` + +## Notes + +- The `updateAndGetPrice` function is the ONLY safe pattern. Separating `updatePriceFeeds` from price reading exposes your protocol to sandwich attacks. +- Confidence validation at 1% (100 BPS) is appropriate for lending protocols. For perpetual DEXes, tighten to 0.5% (50 BPS). +- The `getExistingPrice` function is a view-only convenience for chains with sponsored feeds (Arbitrum, Base, Ethereum). It reverts if no fresh price exists. +- Gas cost: ~120K for update + ~3K for read = ~123K total per call. From 9ae9557c5e3c9c24f68587920c929d7d7225ee94 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:57:43 -0800 Subject: [PATCH 15/20] feat: add pyth-evm docs and resources --- skills/pyth-evm/docs/troubleshooting.md | 129 ++++++++++++++++++ .../pyth-evm/resources/contract-addresses.md | 62 +++++++++ skills/pyth-evm/resources/error-codes.md | 39 ++++++ skills/pyth-evm/resources/feed-ids.md | 72 ++++++++++ 4 files changed, 302 insertions(+) create mode 100644 skills/pyth-evm/docs/troubleshooting.md create mode 100644 skills/pyth-evm/resources/contract-addresses.md create mode 100644 skills/pyth-evm/resources/error-codes.md create mode 100644 skills/pyth-evm/resources/feed-ids.md diff --git a/skills/pyth-evm/docs/troubleshooting.md b/skills/pyth-evm/docs/troubleshooting.md new file mode 100644 index 0000000..aa0a9bb --- /dev/null +++ b/skills/pyth-evm/docs/troubleshooting.md @@ -0,0 +1,129 @@ +# Pyth EVM Troubleshooting Guide + +Common issues and solutions when integrating Pyth price feeds on EVM chains. + +## StalePrice Revert (0x19abf40e) + +**Symptoms:** +- Transaction reverts when calling `getPrice()` or `getPriceNoOlderThan()` +- Error selector `0x19abf40e` + +**Solutions:** + +1. **No update submitted.** Pyth is a pull oracle -- you must call `updatePriceFeeds` before reading. Fetch update data from Hermes and submit it in the same transaction: + ```solidity + uint256 fee = pyth.getUpdateFee(updateData); + pyth.updatePriceFeeds{value: fee}(updateData); + PythStructs.Price memory price = pyth.getPriceNoOlderThan(feedId, 60); + ``` + +2. **maxAge too strict.** If using `getPriceNoOlderThan(id, maxAge)`, the price's `publishTime` must be within `maxAge` seconds of `block.timestamp`. Increase `maxAge` or ensure you are submitting a recent Hermes update. + +3. **Hermes returned stale data.** If your backend caches Hermes responses, the cached update may be too old by the time the transaction lands. Fetch fresh data immediately before transaction submission. + +## Wrong msg.value for Update Fee + +**Symptoms:** +- Transaction reverts on `updatePriceFeeds` call +- `InsufficientFee` error + +**Solutions:** + +1. **Hardcoded fee.** Never hardcode `msg.value`. Always compute it dynamically: + ```solidity + uint256 fee = pyth.getUpdateFee(pythUpdateData); + pyth.updatePriceFeeds{value: fee}(pythUpdateData); + ``` + +2. **Insufficient ETH forwarded from caller.** If your contract accepts `pythUpdateData` from users, ensure `msg.value` covers the fee: + ```solidity + function updateAndAct(bytes[] calldata pythUpdateData) external payable { + uint256 fee = pyth.getUpdateFee(pythUpdateData); + pyth.updatePriceFeeds{value: fee}(pythUpdateData); + // Refund excess + if (msg.value > fee) { + (bool ok, ) = msg.sender.call{value: msg.value - fee}(""); + require(ok); + } + } + ``` + +## Confidence Interval Too Wide + +**Symptoms:** +- Custom `ConfidenceTooWide` revert in your validation logic +- Prices appear correct but fail confidence checks + +**Solutions:** + +1. **Market volatility.** During high-volatility events, confidence intervals widen naturally. Consider using the EMA price (`getEmaPriceNoOlderThan`) which smooths spikes. + +2. **Threshold too strict.** A 0.1% confidence ratio may reject legitimate prices during normal market hours. For most DeFi protocols, 1% (100 basis points) is appropriate. For perpetual DEXes, 0.5% (50 basis points). + +3. **Low-liquidity asset.** Exotic pairs inherently have wider confidence intervals. Adjust thresholds per asset or use a tiered validation approach. + +## Wrong Contract Address + +**Symptoms:** +- Call to Pyth contract reverts with no data +- `cast code
` returns `0x` + +**Solutions:** + +Pyth addresses vary by chain. Common mistake: using the Ethereum address on Arbitrum. + +| Chain Group | Address | +|-------------|---------| +| Ethereum, Avalanche | `0x4305FB66699C3B2702D4d05CF36551390A4c69C6` | +| Arbitrum, Optimism, Base, Polygon, Fantom | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | +| BNB Chain | `0x4D7E825f80bDf85e913E0DD2A2D54927e9dE1594` | + +Verify deployment before integrating: +```bash +cast code 0xff1a0f4744e8582DF1aE09D5611b887B6a12925C --rpc-url $ARBITRUM_RPC +``` + +## Price Exponent Mishandled + +**Symptoms:** +- Prices appear as extremely large or small numbers +- Collateral calculations are off by orders of magnitude + +**Solutions:** + +Pyth prices have a negative exponent (typically `-8`). The formula is: `humanPrice = price * 10^expo`. + +```typescript +// Wrong -- ignoring exponent +const price = Number(pythPrice.price); // 6789000000000 -- NOT $67,890 + +// Correct -- applying exponent +const price = Number(pythPrice.price) * Math.pow(10, pythPrice.expo); // 67890.00 +``` + +In Solidity, when comparing two prices or computing ratios, keep both in raw form (same exponent) to avoid precision loss. + +## Hermes API Returns Empty Data + +**Symptoms:** +- `binary.data` array is empty +- `parsed` array is empty or null + +**Solutions:** + +1. **Invalid feed ID.** Feed IDs must be the full `bytes32` hex string including `0x` prefix. Verify against https://pyth.network/developers/price-feed-ids. + +2. **Rate limiting.** Hermes has rate limits. For production, run your own Hermes instance or use a paid endpoint. + +3. **Network issues.** Try the backup endpoint or a different Hermes region. + +## Debug Checklist + +- [ ] Pyth contract address matches target chain (not a different chain's address) +- [ ] Feed ID is correct `bytes32` (same across all chains) +- [ ] `msg.value` computed via `getUpdateFee(updateData)`, not hardcoded +- [ ] Update and read happen in the same transaction (anti-sandwich) +- [ ] Price exponent applied correctly (`price * 10^expo`) +- [ ] Confidence interval validated before using price +- [ ] `getPriceNoOlderThan` used instead of `getPriceUnsafe` for standalone reads +- [ ] Hermes client fetching fresh data (not stale cache) diff --git a/skills/pyth-evm/resources/contract-addresses.md b/skills/pyth-evm/resources/contract-addresses.md new file mode 100644 index 0000000..f1e2889 --- /dev/null +++ b/skills/pyth-evm/resources/contract-addresses.md @@ -0,0 +1,62 @@ +# Pyth EVM Contract Addresses + +> **Last verified:** March 2026 + +Pyth contract addresses are NOT the same across all chains. Always verify the address for your target chain before deployment. + +## Mainnet Addresses + +| Chain | Pyth Contract | Chain ID | +|-------|---------------|----------| +| Ethereum | `0x4305FB66699C3B2702D4d05CF36551390A4c69C6` | 1 | +| Arbitrum One | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | 42161 | +| Optimism | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | 10 | +| Base | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | 8453 | +| Polygon | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | 137 | +| Avalanche C-Chain | `0x4305FB66699C3B2702D4d05CF36551390A4c69C6` | 43114 | +| BNB Chain | `0x4D7E825f80bDf85e913E0DD2A2D54927e9dE1594` | 56 | +| Gnosis | `0x2880aB155794e7179c9eE2e38200202908C17B43` | 100 | +| Fantom | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | 250 | +| Celo | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | 42220 | +| Mantle | `0xA2aa501b19aff244D90cc15a4Cf739D2725B5729` | 5000 | +| Blast | `0xA2aa501b19aff244D90cc15a4Cf739D2725B5729` | 81457 | +| Sei | `0xff1a0f4744e8582DF1aE09D5611b887B6a12925C` | 1329 | + +## Testnet Addresses + +| Chain | Pyth Contract | Chain ID | +|-------|---------------|----------| +| Sepolia | `0xDd24F84d36BF92C65F92307595335bdFab5Bbd21` | 11155111 | +| Arbitrum Sepolia | `0x4374e5a8b9C22271E9EB878A2AA31DE97aBE67b7` | 421614 | +| Base Sepolia | `0xA2aa501b19aff244D90cc15a4Cf739D2725B5729` | 84532 | +| Optimism Sepolia | `0x0708325268dF9F66270F1401206434524814508b` | 11155420 | + +## Address Groups + +Common pattern -- chains sharing the same contract address use the same deployment bytecode and CREATE2 salt: + +| Address | Chains | +|---------|--------| +| `0x4305FB66...` | Ethereum, Avalanche | +| `0xff1a0f47...` | Arbitrum, Optimism, Base, Polygon, Fantom, Celo, Sei | +| `0x4D7E825f...` | BNB Chain | +| `0xA2aa501b...` | Mantle, Blast | +| `0x2880aB15...` | Gnosis | + +## Verification + +```bash +# Verify Pyth contract deployment +cast code 0xff1a0f4744e8582DF1aE09D5611b887B6a12925C --rpc-url $ARBITRUM_RPC + +# Read Pyth contract version +cast call 0xff1a0f4744e8582DF1aE09D5611b887B6a12925C "version()(string)" --rpc-url $ARBITRUM_RPC + +# Get update fee for empty update (should revert or return minimal fee) +cast call 0xff1a0f4744e8582DF1aE09D5611b887B6a12925C "getUpdateFee(bytes[])(uint256)" "[]" --rpc-url $ARBITRUM_RPC +``` + +## Reference + +- [Pyth Contract Addresses (official)](https://docs.pyth.network/price-feeds/contract-addresses/evm) +- [Pyth Crosschain Deployments (GitHub)](https://github.com/pyth-network/pyth-crosschain/tree/main/target_chains/ethereum) diff --git a/skills/pyth-evm/resources/error-codes.md b/skills/pyth-evm/resources/error-codes.md new file mode 100644 index 0000000..940a78f --- /dev/null +++ b/skills/pyth-evm/resources/error-codes.md @@ -0,0 +1,39 @@ +# Pyth EVM Error Codes + +> **Last verified:** March 2026 + +## Contract Errors + +| Error | Selector | Cause | Fix | +|-------|----------|-------|-----| +| `StalePrice` | `0x19abf40e` | Price data is older than the configured staleness threshold | Call `updatePriceFeeds` before reading, or increase `maxAge` in `getPriceNoOlderThan` | +| `InsufficientFee` | `0x025dbdd4` | `msg.value` is less than the required update fee | Call `getUpdateFee(updateData)` and pass the result as `msg.value` | +| `NoFreshUpdate` | `0x2e7e2a39` | `updatePriceFeeds` called but no update in `updateData` is newer than current on-chain data | Fetch fresh data from Hermes -- the submitted data is already stale | +| `PriceFeedNotFound` | `0x14aebe68` | The requested `bytes32` feed ID does not exist in the Pyth contract | Verify the feed ID at https://pyth.network/developers/price-feed-ids | +| `InvalidUpdateData` | `0xe69ffece` | The `updateData` bytes are malformed or from a different Pyth version | Ensure you are passing raw Hermes binary data with `0x` prefix | +| `InvalidArgument` | `0xa9cb9e0d` | Function called with invalid parameters (e.g., empty arrays) | Check that `priceIds` and `updateData` arrays are non-empty | +| `InvalidUpdateDataSource` | `0x77fcb9cf` | Update data was signed by an unrecognized Wormhole guardian set | Use data from the official Hermes endpoint, not a third-party source | + +## Common Revert Patterns + +| Symptom | Likely Cause | Solution | +|---------|-------------|----------| +| Revert with no data on `updatePriceFeeds` | Wrong Pyth contract address for this chain | Verify address per chain (see resources/contract-addresses.md) | +| `getPriceUnsafe` returns `publishTime = 0` | Feed has never been updated on this chain | Submit an initial `updatePriceFeeds` call first | +| `parsePriceFeedUpdates` reverts | `minPublishTime` / `maxPublishTime` window does not contain any update in the data | Widen the time window or fetch data for the correct timestamp from Hermes | +| Transaction runs out of gas | Multiple feed updates in a single call | Budget ~120K gas per feed for `updatePriceFeeds` | +| `msg.value` refund fails | Calling contract does not accept ETH | Implement `receive()` on the calling contract or remove refund logic | + +## Hermes API Errors + +| HTTP Status | Meaning | Fix | +|-------------|---------|-----| +| 400 | Invalid feed ID format or missing required parameter | Feed IDs must be 64-character hex (without `0x` prefix in query params) | +| 404 | Feed ID not found | Verify feed ID exists at https://pyth.network/developers/price-feed-ids | +| 429 | Rate limit exceeded | Reduce request frequency or run your own Hermes instance | +| 500 | Hermes server error | Retry with exponential backoff; try a different Hermes endpoint | + +## Reference + +- [Pyth SDK Error Definitions](https://github.com/pyth-network/pyth-crosschain/blob/main/target_chains/ethereum/sdk/solidity/PythErrors.sol) +- [Pyth Best Practices](https://docs.pyth.network/price-feeds/best-practices) diff --git a/skills/pyth-evm/resources/feed-ids.md b/skills/pyth-evm/resources/feed-ids.md new file mode 100644 index 0000000..d27e7b4 --- /dev/null +++ b/skills/pyth-evm/resources/feed-ids.md @@ -0,0 +1,72 @@ +# Pyth Price Feed IDs + +> **Last verified:** March 2026 + +Feed IDs are `bytes32` identifiers that are consistent across ALL EVM chains. The same feed ID works on Ethereum, Arbitrum, Base, and every other Pyth-supported chain. + +## Major Pairs + +| Pair | Feed ID | +|------|---------| +| BTC/USD | `0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43` | +| ETH/USD | `0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace` | +| SOL/USD | `0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d` | +| BNB/USD | `0x2f95862b045670cd22bee3114c39763a4a08beeb663b145d283c31d7d1101c4f` | +| AVAX/USD | `0x93da3352f9f1d105fdfe4971cfa80e9dd777bfc5d0f683ebb6e1571f8a528a65` | +| ARB/USD | `0x3fa4252848f9f0a1480be62745a4629d9eb1322aebab8a791e344b3b9c1adcf5` | +| OP/USD | `0x385f64d993f7b77d8182ed5003d97c60aa3361f3cecfe711544d2d59165e9bdf` | +| MATIC/USD | `0x5de33440f6c868ee8c5fc9463ee6f6deca96e7bf3bd3e8c3e6b3b6e73e8b3b6e` | +| DOGE/USD | `0xdcef50dd0a4cd2dcc17e45df1676dcb8a4f6de84bd23d3f9ab82ec3311b3b351` | +| LINK/USD | `0x8ac0c70fff57e9aefdf5edf44b51d62c2d433653cbb2cf5cc06bb115af04d221` | + +## Stablecoins + +| Pair | Feed ID | +|------|---------| +| USDC/USD | `0xeaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a` | +| USDT/USD | `0x2b89b9dc8fdf9f34709a5b106b472f0f39bb6ca9ce04b0fd7f2e971688e2e53b` | +| DAI/USD | `0xb0948a5e5313200c632b51bb5ca32f6de0d36e9950a942d19751e6f20977b50f` | + +## DeFi Tokens + +| Pair | Feed ID | +|------|---------| +| UNI/USD | `0x78d185a741d07edb3412b09008b7c5cfb9bbbd7d568bf00ba737b456ba171501` | +| AAVE/USD | `0x2b9ab1e972a281585084148ba1389800799bd4be63b957507db1349314e47445` | +| MKR/USD | `0x9375299e31c0deb9c6bc378e6329aab44cb4ec3f5b43a4b3293a26f9d3b8e6db` | +| CRV/USD | `0xa19d04ac696c7a6616d291c7e5d1c0c74ad4c7e8d1a17f8053c50a9b8a5a0e12` | + +## L2 Native Tokens + +| Pair | Feed ID | +|------|---------| +| SEI/USD | `0x53614f1cb0c031d4af66c04cb9c756234adad0e1cee85303795091499a4084eb` | +| SUI/USD | `0x23d7315113f5b1d3ba7a83604c44b94d79f4fd69af77f804fc7f920a6dc65744` | +| APT/USD | `0x03ae4db29ed4ae33d323568895aa00337e658e348b37509f5372ae51f0af00d5` | + +## Looking Up Feed IDs + +Full searchable list: https://pyth.network/developers/price-feed-ids + +### Programmatic Lookup + +```typescript +import { HermesClient } from "@pythnetwork/hermes-client"; + +const hermes = new HermesClient("https://hermes.pyth.network"); + +async function findFeedId(symbol: string) { + const feeds = await hermes.getPriceFeeds({ query: symbol }); + for (const feed of feeds) { + console.log(`${feed.attributes.symbol}: 0x${feed.id}`); + } +} + +// Usage +await findFeedId("ETH/USD"); +``` + +## Reference + +- [Pyth Price Feed IDs (official)](https://pyth.network/developers/price-feed-ids) +- [Hermes API -- List Price Feeds](https://hermes.pyth.network/docs/#/rest/latest_price_feeds) From 56a9d64fd751b143d963329e722cea252ae69b0b Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:14:07 -0800 Subject: [PATCH 16/20] feat: add pyth-evm starter template --- skills/pyth-evm/templates/pyth-consumer.sol | 95 +++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 skills/pyth-evm/templates/pyth-consumer.sol diff --git a/skills/pyth-evm/templates/pyth-consumer.sol b/skills/pyth-evm/templates/pyth-consumer.sol new file mode 100644 index 0000000..6d80367 --- /dev/null +++ b/skills/pyth-evm/templates/pyth-consumer.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; +import "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; + +/// @title Pyth Price Consumer Template +/// @notice Starter contract for consuming Pyth price feeds on EVM chains. +/// Implements the atomic update+read pattern to prevent sandwich attacks. +/// @dev CRITICAL: updatePriceFeeds is NOT exposed as a standalone function. +/// All price reads are preceded by an update in the same transaction. +/// +/// Setup: +/// 1. Copy this file to your project +/// 2. Install SDK: forge install pyth-network/pyth-crosschain +/// 3. Add remapping: @pythnetwork/pyth-sdk-solidity/=lib/pyth-crosschain/target_chains/ethereum/sdk/solidity/ +/// 4. Deploy with the Pyth contract address for your target chain +/// +/// Pyth addresses (vary by chain): +/// Ethereum/Avalanche: 0x4305FB66699C3B2702D4d05CF36551390A4c69C6 +/// Arbitrum/Optimism/Base/Polygon: 0xff1a0f4744e8582DF1aE09D5611b887B6a12925C +/// BNB Chain: 0x4D7E825f80bDf85e913E0DD2A2D54927e9dE1594 +contract PythConsumer { + IPyth public immutable pyth; + bytes32 public immutable priceFeedId; + + uint256 private constant MAX_PRICE_AGE = 60; + /// @dev 1% max confidence ratio -- tighten to 50 BPS for perpetual DEXes + uint256 private constant MAX_CONF_RATIO_BPS = 100; + + error NegativePrice(); + error ConfidenceTooWide(); + error InsufficientPayment(); + + event PriceConsumed( + int64 price, + uint64 conf, + int32 expo, + uint256 publishTime + ); + + constructor(address _pyth, bytes32 _priceFeedId) { + pyth = IPyth(_pyth); + priceFeedId = _priceFeedId; + } + + /// @notice Atomically update and read a validated price + /// @param pythUpdateData Price update bytes from Hermes API + /// @return price Raw price (multiply by 10^expo for human-readable) + /// @return expo Price exponent (typically -8) + function updateAndGetPrice(bytes[] calldata pythUpdateData) + external + payable + returns (int64 price, int32 expo) + { + uint256 updateFee = pyth.getUpdateFee(pythUpdateData); + if (msg.value < updateFee) revert InsufficientPayment(); + + pyth.updatePriceFeeds{value: updateFee}(pythUpdateData); + + PythStructs.Price memory pythPrice = pyth.getPriceNoOlderThan( + priceFeedId, + MAX_PRICE_AGE + ); + + if (pythPrice.price <= 0) revert NegativePrice(); + _validateConfidence(pythPrice); + + emit PriceConsumed( + pythPrice.price, + pythPrice.conf, + pythPrice.expo, + pythPrice.publishTime + ); + + _refundExcess(updateFee); + + return (pythPrice.price, pythPrice.expo); + } + + function _validateConfidence(PythStructs.Price memory pythPrice) internal pure { + uint256 absPrice = uint256(uint64(pythPrice.price)); + if ((uint256(pythPrice.conf) * 10_000) / absPrice > MAX_CONF_RATIO_BPS) { + revert ConfidenceTooWide(); + } + } + + function _refundExcess(uint256 fee) internal { + uint256 excess = msg.value - fee; + if (excess > 0) { + (bool ok, ) = msg.sender.call{value: excess}(""); + require(ok); + } + } +} From b94792f58c960b5e802991b1a3707a365d2c99e2 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:42:35 -0800 Subject: [PATCH 17/20] feat: add safe ERC-7579 modular accounts --- skills/safe/SKILL.md | 75 +++++++++ skills/safe/docs/erc-7579-modules.md | 224 +++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 skills/safe/docs/erc-7579-modules.md diff --git a/skills/safe/SKILL.md b/skills/safe/SKILL.md index ff605d0..c06ca01 100644 --- a/skills/safe/SKILL.md +++ b/skills/safe/SKILL.md @@ -614,6 +614,81 @@ contract SpendingGuard is BaseGuard { - Verify Safe proxy points to a legitimate singleton using `cast storage 0x0` -- slot 0 stores the singleton address - Use `SafeL2` singleton on L2 chains for proper event emission +## ERC-7579 Modular Smart Accounts + +ERC-7579 defines a standard interface for modular smart accounts. It specifies four module types -- validators, executors, hooks, and fallback handlers -- that extend account functionality without deploying new proxy contracts. Safe supports ERC-7579 through the Safe7579 adapter. + +### Safe7579 Adapter + +Safe7579 bridges Safe's native Module/Guard system and ERC-7579's standardized module interface. It installs as both a Safe Module and Fallback Handler on an existing Safe, enabling it to accept any ERC-7579-compliant module. Existing Safes can adopt ERC-7579 modules without migration. + +### Module Types + +| Type | Role | Example | +|------|------|---------| +| Validator | Controls who can execute UserOps (signature/auth logic) | OwnableValidator, WebAuthn (passkeys) | +| Executor | Performs automated actions on behalf of the Safe | Scheduled transfers, auto-compounding | +| Hook | Pre/post execution checks on every transaction | Spending limits, address allowlists | +| Fallback | Extends the Safe interface with new function selectors | Custom callback handlers | + +### Key Modules (Rhinestone) + +- **OwnableValidator** -- simple ECDSA owner check, single or multi-owner +- **SmartSessions** -- session keys with policies (time window, value cap, contract/function allowlist) +- **WebAuthn Validator** -- passkey-based signing via the WebAuthn standard +- **Social Recovery** -- guardian-based recovery with threshold and timelock +- **Scheduled Orders** -- cron-like automated execution via keeper network + +### Module Registry (ERC-7484) + +The Module Registry at `0x000000000069E2a187AEFFb852bF3cCdC95151B2` (same address all EVM chains) provides on-chain attestations for module safety. Rhinestone serves as the primary attester. Safes can require registry attestation before module installation as a trust anchor. + +### Installing a Module + +```typescript +import { installModule } from "@rhinestone/module-sdk"; +import { sendUserOperation } from "permissionless"; +import { erc7579Actions } from "permissionless/actions/erc7579"; + +const smartAccountClient = walletClient.extend( + erc7579Actions({ entryPoint: { address: entryPoint07Address, version: "0.7" } }) +); + +const txHash = await smartAccountClient.installModule({ + type: "validator", + address: "0xOwnableValidatorAddress", + context: encodePacked(["address"], [ownerAddress]), +}); +``` + +### Security Considerations + +**Storage collisions (ERC-7201):** Modules sharing storage slots with the Safe proxy can corrupt state. All ERC-7579 modules MUST use ERC-7201 namespaced storage to isolate their state from the Safe's core storage layout. + +**Fallback handler hijacking:** A malicious fallback module intercepts ANY unrecognized function call to the Safe. An attacker who installs a rogue fallback can silently redirect calls meant for legitimate interfaces. + +**`onInstall` reentrancy:** The module `onInstall` callback executes in Safe context during `execTransactionFromModule`. A malicious module can call back into the Safe during installation to add owners, change threshold, or drain funds before the installation transaction completes. + +**Validator front-running:** An attacker observing `validateUserOp` calls on the public mempool can front-run to change validator state (e.g., rotate the approved signer) before the bundler's transaction lands. + +Additional risks: +- Module installation requires owner threshold approval -- a compromised owner set can install malicious modules +- Malicious validators can approve arbitrary UserOps; malicious executors can drain the Safe +- A hook that reverts blocks ALL Safe transactions including module removal -- test hooks on a fork first +- Modules that revert in `onUninstall` become permanently irremovable + +### When to Use ERC-7579 Modules + +| Scenario | Approach | +|----------|----------| +| Simple multisig, no automation | Direct Safe (protocol-kit) | +| Session keys for dApp interactions | Safe + SmartSessions module | +| Automated recurring actions | Safe + Scheduled Orders executor | +| Passkey authentication | Safe + WebAuthn validator | +| Spending policy enforcement | Safe + hook modules | + +> For the full module catalog, installation walkthrough, and production addresses, see `docs/erc-7579-modules.md`. For general account abstraction context (EntryPoint, bundlers, paymasters), see the `account-abstraction` skill. + ## References - [Safe SDK Documentation](https://docs.safe.global/sdk/overview) diff --git a/skills/safe/docs/erc-7579-modules.md b/skills/safe/docs/erc-7579-modules.md new file mode 100644 index 0000000..5734106 --- /dev/null +++ b/skills/safe/docs/erc-7579-modules.md @@ -0,0 +1,224 @@ +# ERC-7579 Modules for Safe + +Deep-dive reference for using ERC-7579 modular smart account modules with Safe via the Safe7579 adapter. Covers module installation, the full module catalog, registry attestation, and production addresses. + +## Prerequisites + +```bash +npm install permissionless viem @rhinestone/module-sdk +``` + +## Creating a Safe Smart Account with Safe7579 + +Use `toSafeSmartAccount` from permissionless.js to create a Safe that supports ERC-7579 modules out of the box. The Safe7579 adapter addresses are passed during account creation. + +```typescript +import { toSafeSmartAccount } from "permissionless/accounts"; +import { createPublicClient, http } from "viem"; +import { sepolia } from "viem/chains"; +import { entryPoint07Address } from "viem/account-abstraction"; +import { privateKeyToAccount } from "viem/accounts"; + +const publicClient = createPublicClient({ + chain: sepolia, + transport: http(process.env.RPC_URL), +}); + +const owner = privateKeyToAccount( + process.env.PRIVATE_KEY as `0x${string}` +); + +const safeAccount = await toSafeSmartAccount({ + client: publicClient, + owners: [owner], + version: "1.4.1", + entryPoint: { + address: entryPoint07Address, + version: "0.7", + }, + safe4337ModuleAddress: "0x7579EE8307284F293B1927136486880611F20002", + erc7579LaunchpadAddress: "0x7579011aB74c46090561ea277Ba79D510c6C00ff", +}); +``` + +## Installing and Managing Modules + +Extend the smart account client with `erc7579Actions` to access module management methods. + +```typescript +import { createSmartAccountClient } from "permissionless"; +import { erc7579Actions } from "permissionless/actions/erc7579"; +import { http } from "viem"; +import { sepolia } from "viem/chains"; +import { entryPoint07Address } from "viem/account-abstraction"; + +const smartAccountClient = createSmartAccountClient({ + account: safeAccount, + chain: sepolia, + bundlerTransport: http( + `https://api.pimlico.io/v2/sepolia/rpc?apikey=${process.env.PIMLICO_API_KEY}` + ), +}).extend( + erc7579Actions({ + entryPoint: { address: entryPoint07Address, version: "0.7" }, + }) +); + +// Install a validator module +const installHash = await smartAccountClient.installModule({ + type: "validator", + address: OWNABLE_VALIDATOR_ADDRESS, + context: encodePacked(["address"], [owner.address]), +}); + +// Check if a module is installed +const isInstalled = await smartAccountClient.isModuleInstalled({ + type: "validator", + address: OWNABLE_VALIDATOR_ADDRESS, + context: "0x", +}); + +// Uninstall a module +const uninstallHash = await smartAccountClient.uninstallModule({ + type: "validator", + address: OWNABLE_VALIDATOR_ADDRESS, + context: encodePacked(["address"], [owner.address]), +}); +``` + +## Deploying a New Safe with the Launchpad + +The ERC-7579 Launchpad enables deploying a new Safe with modules pre-installed in a single transaction. The launchpad acts as a temporary implementation during the Safe's `setup` call, installs the specified modules, then hands control to the Safe singleton. + +The launchpad address (`0x7579011aB74c46090561ea277Ba79D510c6C00ff`) is passed to `toSafeSmartAccount` as `erc7579LaunchpadAddress`. When the account does not yet exist on-chain, the first UserOperation triggers deployment through the launchpad, which: + +1. Deploys the Safe proxy pointing to the launchpad as initial implementation +2. Calls `setupSafe()` which installs the Safe7579 adapter and all initial modules +3. Upgrades the proxy to point to the real Safe singleton + +No separate deployment step is required -- the SDK handles this automatically on the first `sendUserOperation` call. + +## Module Catalog + +### OwnableValidator + +Simple ECDSA-based ownership check. Supports single owner or multi-owner with threshold. The most basic validator -- use when you need standard EOA key authorization. + +- **Type:** Validator +- **Use case:** Default signer for Safe, replace Safe's native owner system with ERC-7579 compatible validation +- **Install context:** ABI-encoded owner address(es) + +### SmartSessions + +Session keys with granular policy enforcement. Each session defines a signer (temporary key) bound to a set of policies that restrict what that key can do. + +- **Type:** Validator +- **Use case:** dApp-scoped sessions, automated trading within limits, gasless UX for specific flows +- **Policies available:** + - Time range (validAfter / validUntil) + - Value limit per call + - Contract address allowlist + - Function selector allowlist + - Cumulative spending cap + - Usage count limit + +### WebAuthn Validator + +Passkey-based signing using the WebAuthn standard. The user authenticates via device biometrics (Touch ID, Face ID, Windows Hello) and the validator verifies the WebAuthn assertion on-chain. + +- **Type:** Validator +- **Use case:** Seedless onboarding, mobile-native authentication, consumer wallets +- **Install context:** Public key coordinates (x, y) from the WebAuthn registration ceremony + +### Social Recovery + +Guardian-based account recovery. A set of trusted addresses (guardians) can collectively initiate a recovery to replace the account's validators after a timelock period. + +- **Type:** Executor (initiates recovery) + Validator (new owner after recovery) +- **Use case:** Key loss recovery without seed phrases, enterprise backup mechanisms +- **Parameters:** Guardian addresses, threshold (e.g., 3-of-5), timelock duration + +### Scheduled Orders + +Automated execution triggered by a keeper network. Defines recurring actions (token transfers, yield harvesting, rebalancing) with cron-like scheduling. + +- **Type:** Executor +- **Use case:** DCA strategies, recurring payments, auto-compounding yield positions +- **Execution:** Keeper calls the executor module which submits the pre-defined action through the Safe + +## Module Registry (ERC-7484) + +The Module Registry provides on-chain attestations about module security and compatibility. Before installing a module, the Safe (or its frontend) can query the registry to verify the module has been attested by a trusted entity. + +### Registry Details + +| Property | Value | +|----------|-------| +| Registry Address | `0x000000000069E2a187AEFFb852bF3cCdC95151B2` | +| Deployment | Deterministic across all EVM chains | +| Primary Attester | Rhinestone (`0x000000333034E9f539ce08819E12c1b8Cb29084d`) | +| Attestation Model | Attesters stake reputation; attestations are per-module, per-chain | + +### Querying the Registry + +```typescript +import { getModule, getAttestation } from "@rhinestone/module-sdk"; + +const module = getModule({ + module: OWNABLE_VALIDATOR_ADDRESS, + type: "validator", + initData: encodePacked(["address"], [owner.address]), +}); + +// The registry check is performed automatically by Safe7579 during +// installModule if the Safe has a registry configured. To manually +// verify before installation: +const attestation = await publicClient.readContract({ + address: "0x000000000069E2a187AEFFb852bF3cCdC95151B2", + abi: registryAbi, + functionName: "getAttestation", + args: [OWNABLE_VALIDATOR_ADDRESS, "0x000000333034E9f539ce08819E12c1b8Cb29084d"], +}); +``` + +## Key Packages + +| Package | Version | Purpose | +|---------|---------|---------| +| `permissionless` | ^0.2 | Smart account client, ERC-7579 actions, bundler integration | +| `@rhinestone/module-sdk` | ^0.4.0 | Module installation helpers, registry queries | +| `@rhinestone/sdk` | ^0.1 | Higher-level Rhinestone platform SDK | +| `viem` | ^2.21 | Transport, encoding, chain definitions | + +## Production Addresses + +> **Last verified:** March 2026 + +| Contract | Address | Notes | +|----------|---------|-------| +| EntryPoint v0.7 | `0x0000000071727De22E5E9d8BAf0edAc6f37da032` | Singleton, all EVM chains | +| Module Registry (ERC-7484) | `0x000000000069E2a187AEFFb852bF3cCdC95151B2` | Singleton, all EVM chains | +| Safe Singleton v1.4.1 | `0x41675C099F32341bf84BFc5382aF534df5C7461a` | Deterministic deployment | +| Safe7579 Adapter | `0x7579EE8307284F293B1927136486880611F20002` | Safe Module + Fallback Handler | +| ERC-7579 Launchpad | `0x7579011aB74c46090561ea277Ba79D510c6C00ff` | First-deploy bootstrapper | +| Rhinestone Attester | `0x000000333034E9f539ce08819E12c1b8Cb29084d` | Primary registry attester | + +## Production Adoption + +Safe secures over $100B in assets across EVM chains. ERC-7579 adoption is growing across the smart account ecosystem: + +- **Safe** -- native ERC-7579 support via Safe7579 adapter +- **ZeroDev Kernel** -- ERC-7579 modular account, session keys, passkeys +- **Biconomy Nexus** -- ERC-7579 compatible account with module marketplace +- **OKX Wallet** -- integrated ERC-7579 smart account for mobile + +The Rhinestone Module Registry provides a shared trust layer, enabling any ERC-7579 module to work across all compatible account implementations without per-vendor integration. + +## References + +- [ERC-7579: Minimal Modular Smart Accounts](https://eips.ethereum.org/EIPS/eip-7579) +- [ERC-7484: Registry for Module Smart Accounts](https://eips.ethereum.org/EIPS/eip-7484) +- [Safe7579 Documentation](https://docs.rhinestone.wtf/module-sdk/account-integrations/safe) +- [Rhinestone Module SDK](https://github.com/rhinestonewtf/module-sdk) +- [permissionless.js ERC-7579 Actions](https://docs.pimlico.io/permissionless/reference/erc7579-actions) +- [Safe Modular Smart Accounts Guide](https://docs.safe.global/advanced/erc-7579/overview) From 6e8fe00fec67302397945fcdf64884b6958a9ac5 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:08:19 -0800 Subject: [PATCH 18/20] fix: normalize pyth frontmatter and add EVM cross-ref --- skills/pyth/SKILL.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/skills/pyth/SKILL.md b/skills/pyth/SKILL.md index 84a84b6..cd7298f 100644 --- a/skills/pyth/SKILL.md +++ b/skills/pyth/SKILL.md @@ -1,10 +1,17 @@ --- name: pyth -creator: raunit-dev -description: Complete guide for Pyth Network - decentralized oracle providing real-time price feeds for DeFi. Covers price feed integration, confidence intervals, EMA prices, on-chain CPI, off-chain fetching, and streaming updates for Solana applications. +description: "Pyth Network oracle for Solana — decentralized real-time price feeds for DeFi. Covers Anchor CPI integration, confidence intervals, EMA prices, on-chain/off-chain fetching, and streaming updates. For EVM chains, see the pyth-evm skill." +license: Apache-2.0 metadata: + author: raunit-dev + version: "1.0" chain: solana category: Oracles +tags: + - pyth + - oracle + - price-feeds + - solana --- # Pyth Network Development Guide @@ -546,3 +553,25 @@ pyth/ └── docs/ └── troubleshooting.md # Common issues and solutions ``` + +## Pyth on EVM Chains + +This skill covers Pyth integration for **Solana** applications using Anchor CPI. For EVM chain integration (Ethereum, Arbitrum, Base, Optimism, Polygon, and 50+ other chains), see the **`pyth-evm`** skill. + +Key differences between Pyth Solana and Pyth EVM: + +| Aspect | Pyth Solana (this skill) | Pyth EVM (`pyth-evm` skill) | +|--------|--------------------------|---------------------------| +| Contract interface | Anchor CPI to Pyth program | Solidity `IPyth` interface | +| Price update | Pull from Pyth accumulator account | Submit `bytes[]` via `updatePriceFeeds` | +| Contract address | Single Pyth program on Solana | Varies per EVM chain | +| Gas/compute | Compute units | ~120-150K gas per feed update | +| SDK | `@pythnetwork/pyth-solana-receiver` | `@pythnetwork/hermes-client` v3.1.0 | + +Price feed IDs (bytes32) are the **same across all chains** — a BTC/USD feed ID works on both Solana and Ethereum. + +## Related Skills + +- **`pyth-evm`** — Pyth oracle integration for EVM chains (Solidity + TypeScript) +- **`chainlink`** — Push oracle alternative on EVM chains +- **`redstone`** — Another pull oracle for EVM chains From dd74a0c5210f09999575c390d26ee350f18117e6 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:26:54 -0800 Subject: [PATCH 19/20] docs: add alternative oracles cross-reference to chainlink --- skills/chainlink/SKILL.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/skills/chainlink/SKILL.md b/skills/chainlink/SKILL.md index b84bca0..e79819b 100644 --- a/skills/chainlink/SKILL.md +++ b/skills/chainlink/SKILL.md @@ -594,6 +594,18 @@ function getSafePrice(AggregatorV3Interface feed) internal view returns (uint256 - Always allowlist source chains and sender addresses on your receiver contract. Without this, anyone on any supported chain can send messages to your contract. - Handle message failures gracefully. If `_ccipReceive` reverts, the message can be manually executed later, but your contract should not end up in an inconsistent state from partial execution. +## Alternative Oracles + +For use cases where Chainlink's push model isn't optimal, consider these alternatives: + +**Pyth Network** (`pyth-evm` skill) — Pull oracle model where consumers fetch and submit price updates on-demand. Best for: sub-second price freshness (~400ms on Pythnet), confidence intervals (statistical uncertainty bounds), MEV-protected liquidations via Express Relay, and non-EVM chains (Solana, Sui, Aptos). Trade-off: consumers pay gas for price updates (~120-150K gas per feed). + +**When to use Chainlink vs Pyth:** +- **Chainlink**: Zero-cost reads (DON sponsors updates), broadest EVM feed coverage (1000+), VRF/CCIP/Automation ecosystem, well-established data quality +- **Pyth**: Sub-second freshness, confidence intervals, historical price verification, MEV protection, 50+ EVM chains + non-EVM + +See also: `redstone` skill for another pull oracle alternative. + ## References - [Chainlink Price Feed Addresses](https://docs.chain.link/data-feeds/price-feeds/addresses) From a01d884b2136498420ac9f81e67dc5e9c6afc628 Mon Sep 17 00:00:00 2001 From: 0xinit <28729137+0xinit@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:41:11 -0800 Subject: [PATCH 20/20] chore: update marketplace registry with 4 new skills --- .claude-plugin/marketplace.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 73af131..94a8a5a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -165,12 +165,24 @@ "description": "ethers.js v6 TypeScript/JavaScript Ethereum library — Provider, Signer, Contract interaction, ABI encoding/decoding, event filters, ENS resolution, and BigNumber-to-bigint migration from v5. Covers JsonRpcProvider, BrowserProvider, Wallet, ContractFactory, and typed contract interfaces.", "category": "Dev Tools" }, + { + "name": "evm-nfts", + "source": "./skills/evm-nfts", + "description": "ERC-721 and ERC-1155 NFT development patterns for EVM chains. Covers minting, metadata, royalties, marketplace integration with Seaport, and security pitfalls. Uses OpenZeppelin v5.6.1.", + "category": "NFT & Tokens" + }, { "name": "evm-testing", "source": "./skills/evm-testing", "description": "Comprehensive testing patterns for EVM smart contracts. Covers unit tests, fuzz testing, invariant testing, fork testing, and gas optimization with Foundry and Hardhat. Focuses on patterns and techniques — not CLI usage or project setup.", "category": "Dev Tools" }, + { + "name": "farcaster", + "source": "./skills/farcaster", + "description": "Onchain social protocol with Neynar API, Frames v2 Mini Apps, and transaction frames. Covers Snapchain architecture, FID registry on OP Mainnet, and Warpcast integration.", + "category": "Infrastructure" + }, { "name": "foundry", "source": "./skills/foundry", @@ -363,6 +375,12 @@ "description": "Polygon ecosystem development — PoS chain deployment, zkEVM patterns, AggLayer interop, POL token migration, and bridging across Polygon chains.", "category": "L2 & Alt-L1" }, + { + "name": "privy", + "source": "./skills/privy", + "description": "Embedded wallet SDK for dApps with social login, email, and passkey auth. Covers React SDK, server-side JWT verification, wallet management, and smart wallet integration. Acquired by Stripe (2025).", + "category": "Frontend" + }, { "name": "pumpfun", "source": "./skills/pumpfun", @@ -372,7 +390,13 @@ { "name": "pyth", "source": "./skills/pyth", - "description": "Complete guide for Pyth Network - decentralized oracle providing real-time price feeds for DeFi. Covers price feed integration, confidence intervals, EMA prices, on-chain CPI, off-chain fetching, and streaming updates for Solana applications.", + "description": "Pyth Network oracle for Solana — decentralized real-time price feeds for DeFi. Covers Anchor CPI integration, confidence intervals, EMA prices, on-chain/off-chain fetching, and streaming updates. For EVM chains, see the pyth-evm skill.", + "category": "Oracles" + }, + { + "name": "pyth-evm", + "source": "./skills/pyth-evm", + "description": "Pyth pull oracle integration for EVM chains. Covers price feed updates, Hermes API, confidence intervals, sponsored feeds, Express Relay for MEV-protected liquidations, and Solidity/TypeScript patterns.", "category": "Oracles" }, {