diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c16738dbb..b03db3cdf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,13 @@ jobs:
with:
node-version: "22"
+ # Build shared chain-utils first (required by website)
+ - name: Build chain-utils
+ run: |
+ cd shared/chain-utils
+ npm ci
+ npm run build
+
# Install website dependencies
- name: Install website dependencies
run: |
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 459b82ec5..82a566184 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -39,9 +39,16 @@ jobs:
node-version: "22"
cache: "npm"
cache-dependency-path: |
+ shared/chain-utils/package-lock.json
website/package-lock.json
scw_js/package-lock.json
+ - name: Build chain-utils (local dependency)
+ working-directory: ./shared/chain-utils
+ run: |
+ npm ci
+ npm run build
+
- name: Install dependencies (website)
working-directory: ./website
run: npm ci
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index cce40a6ac..9598ebc3e 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -14,6 +14,12 @@ jobs:
- name: Install and Build π§ # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
run: |
+ # Build shared chain-utils first (required by website)
+ cd shared/chain-utils
+ npm ci
+ npm run build
+ cd ../..
+ # Build website
cd website
npm ci
npm run build
diff --git a/eth/DEPLOY_SUPPORT_V2_GUIDE.md b/eth/DEPLOY_SUPPORT_V2_GUIDE.md
index ee2caffc0..9acf10ab5 100644
--- a/eth/DEPLOY_SUPPORT_V2_GUIDE.md
+++ b/eth/DEPLOY_SUPPORT_V2_GUIDE.md
@@ -14,8 +14,8 @@ This guide explains how to deploy the SupportV2 contract to Optimism and Base ma
SupportV2 is an evolution of the original Support contract with these key changes:
-| Feature | Support (v1) | SupportV2 |
-|---------|--------------|-----------|
+| Feature | Support (v1) | SupportV2 |
+| -------------------- | ------------- | ------------------------ |
| `donate()` signature | `donate(url)` | `donate(url, recipient)` |
| Upgradeability | None | UUPS proxy |
@@ -82,17 +82,17 @@ networks: {
### Testnets β
-| Network | Proxy Address | Implementation | Verified |
-|---------|---------------|----------------|----------|
-| Optimism Sepolia | `0x9859431b682e861b19e87Db14a04944BC747AB6d` | - | β
|
-| Base Sepolia | `0xaB44BE78499721b593a0f4BE2099b246e9C53B57` | - | β
|
+| Network | Proxy Address | Implementation | Verified |
+| ---------------- | -------------------------------------------- | -------------- | -------- |
+| Optimism Sepolia | `0x9859431b682e861b19e87Db14a04944BC747AB6d` | - | β
|
+| Base Sepolia | `0xaB44BE78499721b593a0f4BE2099b246e9C53B57` | - | β
|
### Mainnets β
-| Network | Proxy Address | Implementation | Verified |
-|---------|---------------|----------------|----------|
-| Optimism | `0x4ca63f8A4Cd56287E854f53E18ca482D74391316` | `0x011881999565F10aB2C62912878050Fb5deC10ac` | β
|
-| Base | `0xB70EA4d714Fed01ce20E93F9033008BadA1c8694` | `0x314B07fBd33A7343479e99E6682D5Ee1da7F17c1` | β
|
+| Network | Proxy Address | Implementation | Verified |
+| -------- | -------------------------------------------- | -------------------------------------------- | -------- |
+| Optimism | `0x4ca63f8A4Cd56287E854f53E18ca482D74391316` | `0x011881999565F10aB2C62912878050Fb5deC10ac` | β
|
+| Base | `0xB70EA4d714Fed01ce20E93F9033008BadA1c8694` | `0x314B07fBd33A7343479e99E6682D5Ee1da7F17c1` | β
|
## Configuration File
@@ -135,10 +135,13 @@ Edit `scripts/deploy-support-v2.config.json`:
}
}
```
+
"environment": "mainnet"
- }
+
}
-```
+}
+
+````
### Configuration Options
@@ -175,9 +178,10 @@ cd eth
# Edit config: set validateOnly: true
npx hardhat run scripts/deploy-support-v2.ts --network optimisticEthereum
-```
+````
**What happens:**
+
- β
Validates contract compiles successfully
- β
Checks OpenZeppelin upgrade patterns
- β
Verifies UUPS proxy compatibility
@@ -192,6 +196,7 @@ npx hardhat run scripts/deploy-support-v2.ts --network optimisticEthereum
```
**What happens:**
+
- β
Shows deployment parameters
- β
Validates configuration
- β
Simulates deployment flow
@@ -205,6 +210,7 @@ npx hardhat run scripts/deploy-support-v2.ts --network optimisticEthereum
```
**For Base Mainnet:**
+
```bash
npx hardhat run scripts/deploy-support-v2.ts --network base
```
@@ -282,11 +288,13 @@ npx hardhat run scripts/verify-contract.ts --network base
```
The script will:
+
1. Verify the implementation contract
2. Attempt proxy verification with multiple strategies
3. Handle "Already Verified" gracefully
**Alternative (simple):**
+
```bash
npx hardhat verify --network optimisticEthereum
```
@@ -302,7 +310,7 @@ const SUPPORT_V2_ADDRESSES: Record = {
[baseSepolia.id]: "0xaB44BE78499721b593a0f4BE2099b246e9C53B57",
// Mainnets - ADD THESE AFTER DEPLOYMENT
[optimism.id]: "0x...", // β Optimism Mainnet proxy address
- [base.id]: "0x...", // β Base Mainnet proxy address
+ [base.id]: "0x...", // β Base Mainnet proxy address
};
// Update default chain for production
@@ -369,6 +377,7 @@ Ensure `scripts/deploy-support-v2.config.json` exists.
### "OpenZeppelin upgrade validation failed"
Check the contract for:
+
- Missing `__gap` arrays in base contracts
- Storage layout incompatibilities
diff --git a/eth/SUPPORT_V2_PROPOSAL.md b/eth/SUPPORT_V2_PROPOSAL.md
index d33ec6b81..c441a7eaf 100644
--- a/eth/SUPPORT_V2_PROPOSAL.md
+++ b/eth/SUPPORT_V2_PROPOSAL.md
@@ -7,40 +7,40 @@
## 0. Implementierungs-Γbersicht
-| Phase | Beschreibung | Status |
-|-------|--------------|--------|
-| Phase 1 | Contract, Tests, Deployment Script | β
ABGESCHLOSSEN |
-| Phase 2 | Multi-Chain & Testing | β
ABGESCHLOSSEN |
-| Phase 3 | Frontend Integration | β
ABGESCHLOSSEN (ETH only) |
-| Phase 4 | Production Deployment | β
ABGESCHLOSSEN |
+| Phase | Beschreibung | Status |
+| ------- | ---------------------------------- | --------------------------- |
+| Phase 1 | Contract, Tests, Deployment Script | β
ABGESCHLOSSEN |
+| Phase 2 | Multi-Chain & Testing | β
ABGESCHLOSSEN |
+| Phase 3 | Frontend Integration | β
ABGESCHLOSSEN (ETH only) |
+| Phase 4 | Production Deployment | β
ABGESCHLOSSEN |
### Deployment Adressen
#### Mainnets
-| Chain | Proxy Address | Deployed |
-|-------|---------------|----------|
+| Chain | Proxy Address | Deployed |
+| -------- | -------------------------------------------- | ---------- |
| Optimism | `0x4ca63f8A4Cd56287E854f53E18ca482D74391316` | 24.01.2026 |
-| Base | `0xB70EA4d714Fed01ce20E93F9033008BadA1c8694` | 25.01.2026 |
+| Base | `0xB70EA4d714Fed01ce20E93F9033008BadA1c8694` | 25.01.2026 |
#### Testnets
-| Chain | Proxy Address | Deployed |
-|-------|---------------|----------|
+| Chain | Proxy Address | Deployed |
+| ---------------- | -------------------------------------------- | ---------- |
| Optimism Sepolia | `0x9859431b682e861b19e87Db14a04944BC747AB6d` | 20.01.2026 |
-| Base Sepolia | `0xaB44BE78499721b593a0f4BE2099b246e9C53B57` | 21.01.2026 |
+| Base Sepolia | `0xaB44BE78499721b593a0f4BE2099b246e9C53B57` | 21.01.2026 |
---
## 1. Feature-Γbersicht
-| Feature | Beschreibung |
-|---------|--------------|
-| π UUPS Upgradeable | Proxy-Architektur fΓΌr spΓ€tere Updates |
-| π° ETH Donations | `donate(url, recipient)` |
-| πͺ EIP-3009 Tokens | `donateToken(...)` fΓΌr USDC und kompatible Tokens (permissionless) |
-| π Like-Counting | On-chain `urlLikes` Mapping |
-| π Multi-Chain | Optimism + Base |
+| Feature | Beschreibung |
+| ------------------- | ------------------------------------------------------------------ |
+| π UUPS Upgradeable | Proxy-Architektur fΓΌr spΓ€tere Updates |
+| π° ETH Donations | `donate(url, recipient)` |
+| πͺ EIP-3009 Tokens | `donateToken(...)` fΓΌr USDC und kompatible Tokens (permissionless) |
+| π Like-Counting | On-chain `urlLikes` Mapping |
+| π Multi-Chain | Optimism + Base |
---
@@ -124,12 +124,12 @@ import "./interfaces/IEIP3009.sol";
* Multi-chain: Deploy on Optimism + Base
*/
contract SupportV2 is UUPSUpgradeable, OwnableUpgradeable, ReentrancyGuardUpgradeable {
-
+
/// @notice Contract version for upgrade tracking
uint256 public constant VERSION = 1;
-
+
mapping(bytes32 => uint256) public urlLikes;
-
+
event Donation(
address indexed from,
address indexed recipient,
@@ -138,35 +138,35 @@ contract SupportV2 is UUPSUpgradeable, OwnableUpgradeable, ReentrancyGuardUpgrad
uint256 amount,
address token // address(0) = ETH
);
-
+
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
-
+
function initialize(address _owner) public initializer {
__Ownable_init(_owner);
__ReentrancyGuard_init();
__UUPSUpgradeable_init();
}
-
+
// ETH Donation
function donate(
- string calldata _url,
+ string calldata _url,
address _recipient
) external payable nonReentrant {
require(msg.value > 0, "No ETH sent");
require(_recipient != address(0), "Invalid recipient");
-
+
bytes32 urlHash = keccak256(bytes(_url));
urlLikes[urlHash]++;
-
+
(bool success, ) = payable(_recipient).call{value: msg.value}("");
require(success, "Transfer failed");
-
+
emit Donation(msg.sender, _recipient, urlHash, _url, msg.value, address(0));
}
-
+
// EIP-3009 Token Donation (v,r,s format) - Permissionless
function donateToken(
string calldata _url,
@@ -183,7 +183,7 @@ contract SupportV2 is UUPSUpgradeable, OwnableUpgradeable, ReentrancyGuardUpgrad
require(_recipient != address(0), "Invalid recipient");
require(_amount > 0, "Amount must be > 0");
require(_token != address(0), "Invalid token");
-
+
IEIP3009(_token).transferWithAuthorization(
msg.sender,
_recipient,
@@ -195,17 +195,17 @@ contract SupportV2 is UUPSUpgradeable, OwnableUpgradeable, ReentrancyGuardUpgrad
_r,
_s
);
-
+
bytes32 urlHash = keccak256(bytes(_url));
urlLikes[urlHash]++;
-
+
emit Donation(msg.sender, _recipient, urlHash, _url, _amount, _token);
}
-
+
function getLikesForUrl(string calldata _url) external view returns (uint256) {
return urlLikes[keccak256(bytes(_url))];
}
-
+
function _authorizeUpgrade(address) internal override onlyOwner {}
}
```
@@ -216,12 +216,12 @@ contract SupportV2 is UUPSUpgradeable, OwnableUpgradeable, ReentrancyGuardUpgrad
## 3. Token-Adressen
-| Token | Chain | Adresse |
-|-------|-------|---------|
-| USDC | Optimism | `0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85` |
-| USDC | Base | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` |
-| USDC | Optimism Sepolia | `0x5fd84259d66Cd46123540766Be93DFE6D43130D7` |
-| USDC | Base Sepolia | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |
+| Token | Chain | Adresse |
+| ----- | ---------------- | -------------------------------------------- |
+| USDC | Optimism | `0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85` |
+| USDC | Base | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` |
+| USDC | Optimism Sepolia | `0x5fd84259d66Cd46123540766Be93DFE6D43130D7` |
+| USDC | Base Sepolia | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |
---
@@ -230,42 +230,37 @@ contract SupportV2 is UUPSUpgradeable, OwnableUpgradeable, ReentrancyGuardUpgrad
### 4.1 EIP-3009 Signatur (v,r,s Format)
```typescript
-import { hexToSignature } from 'viem';
-
-async function signEIP3009(
- token: Address,
- recipient: Address,
- amount: bigint,
- chainId: number
-) {
- const nonce = `0x${crypto.randomUUID().replace(/-/g, '')}` as `0x${string}`;
+import { hexToSignature } from "viem";
+
+async function signEIP3009(token: Address, recipient: Address, amount: bigint, chainId: number) {
+ const nonce = `0x${crypto.randomUUID().replace(/-/g, "")}` as `0x${string}`;
const validAfter = 0n;
const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600);
-
+
const signature = await walletClient.signTypedData({
domain: {
- name: 'USD Coin',
- version: '2',
+ name: "USD Coin",
+ version: "2",
chainId,
verifyingContract: token,
},
types: {
TransferWithAuthorization: [
- { name: 'from', type: 'address' },
- { name: 'to', type: 'address' },
- { name: 'value', type: 'uint256' },
- { name: 'validAfter', type: 'uint256' },
- { name: 'validBefore', type: 'uint256' },
- { name: 'nonce', type: 'bytes32' },
+ { name: "from", type: "address" },
+ { name: "to", type: "address" },
+ { name: "value", type: "uint256" },
+ { name: "validAfter", type: "uint256" },
+ { name: "validBefore", type: "uint256" },
+ { name: "nonce", type: "bytes32" },
],
},
- primaryType: 'TransferWithAuthorization',
+ primaryType: "TransferWithAuthorization",
message: { from: userAddress, to: recipient, value: amount, validAfter, validBefore, nonce },
});
-
+
// Split signature into v, r, s for contract
const { v, r, s } = hexToSignature(signature);
-
+
return { v: Number(v), r, s, nonce, validAfter, validBefore };
}
```
@@ -276,7 +271,7 @@ async function signEIP3009(
// ETH
await writeContract({
...supportV2Config,
- functionName: 'donate',
+ functionName: "donate",
args: [url, recipient],
value: amount,
});
@@ -285,7 +280,7 @@ await writeContract({
const sig = await signEIP3009(USDC_ADDRESS, recipient, amount, chainId);
await writeContract({
...supportV2Config,
- functionName: 'donateToken',
+ functionName: "donateToken",
args: [url, recipient, USDC_ADDRESS, amount, sig.validAfter, sig.validBefore, sig.nonce, sig.v, sig.r, sig.s],
});
```
@@ -296,58 +291,58 @@ await writeContract({
### Phase 1: Contract β
ABGESCHLOSSEN
-| Schritt | Beschreibung | Status |
-|---------|--------------|--------|
-| 1.1 | `contracts/interfaces/IEIP3009.sol` | β
|
-| 1.2 | `contracts/SupportV2.sol` | β
|
-| 1.3 | `test/SupportV2_Functional.ts` + `test/SupportV2_Deployment.ts` | β
|
-| 1.4 | `scripts/deploy-support-v2.ts` + Config | β
|
+| Schritt | Beschreibung | Status |
+| ------- | --------------------------------------------------------------- | ------ |
+| 1.1 | `contracts/interfaces/IEIP3009.sol` | β
|
+| 1.2 | `contracts/SupportV2.sol` | β
|
+| 1.3 | `test/SupportV2_Functional.ts` + `test/SupportV2_Deployment.ts` | β
|
+| 1.4 | `scripts/deploy-support-v2.ts` + Config | β
|
### Phase 2: Multi-Chain & Testing β
ABGESCHLOSSEN
-| Schritt | Beschreibung | Status |
-|---------|--------------|--------|
-| 2.1 | Base + Base Sepolia zu `hardhat.config.ts` hinzufΓΌgen | β
|
-| 2.2 | Deploy auf Optimism Sepolia + Base Sepolia | β
|
-| 2.3 | ABI Export (`abi/contracts/SupportV2.ts`) | β
|
-| 2.4 | `notebooks/support_v2_demo.ipynb` β Deno TypeScript Notebook | β
|
+| Schritt | Beschreibung | Status |
+| ------- | ------------------------------------------------------------ | ------ |
+| 2.1 | Base + Base Sepolia zu `hardhat.config.ts` hinzufΓΌgen | β
|
+| 2.2 | Deploy auf Optimism Sepolia + Base Sepolia | β
|
+| 2.3 | ABI Export (`abi/contracts/SupportV2.ts`) | β
|
+| 2.4 | `notebooks/support_v2_demo.ipynb` β Deno TypeScript Notebook | β
|
### Phase 3: Frontend β
ABGESCHLOSSEN (ETH only)
-| Schritt | Beschreibung | Status |
-|---------|--------------|--------|
-| 3.1 | `wagmi.config.ts` β Base + Base Sepolia Chains hinzufΓΌgen | β
|
-| 3.2 | `getChain.ts` β SupportV2 Multi-Chain Config | β
|
-| 3.3 | ~~EIP-3009 Signatur-Helper~~ (deprioritisiert) | βΈοΈ |
-| 3.4 | ~~Token-Auswahl UI (ETH / USDC)~~ (deprioritisiert) | βΈοΈ |
-| 3.5 | `useSupportAction.ts` β Multi-Chain Hook mit Auto-Switch | β
|
-| 3.6 | Legacy Support Config entfernt (`supportContractConfig`) | β
|
-| 3.7 | Unit Tests fΓΌr `useSupportAction` | β
|
-| 3.8 | `VITE_USE_TESTNET` Env-Variable fΓΌr Testnet-Modus | β
|
-| 3.9 | Aggregierte Likes von beiden Chains | β
|
+| Schritt | Beschreibung | Status |
+| ------- | --------------------------------------------------------- | ------ |
+| 3.1 | `wagmi.config.ts` β Base + Base Sepolia Chains hinzufΓΌgen | β
|
+| 3.2 | `getChain.ts` β SupportV2 Multi-Chain Config | β
|
+| 3.3 | ~~EIP-3009 Signatur-Helper~~ (deprioritisiert) | βΈοΈ |
+| 3.4 | ~~Token-Auswahl UI (ETH / USDC)~~ (deprioritisiert) | βΈοΈ |
+| 3.5 | `useSupportAction.ts` β Multi-Chain Hook mit Auto-Switch | β
|
+| 3.6 | Legacy Support Config entfernt (`supportContractConfig`) | β
|
+| 3.7 | Unit Tests fΓΌr `useSupportAction` | β
|
+| 3.8 | `VITE_USE_TESTNET` Env-Variable fΓΌr Testnet-Modus | β
|
+| 3.9 | Aggregierte Likes von beiden Chains | β
|
**Frontend-Γnderungen (25. Januar 2026):**
- `website/.env`: `VITE_USE_TESTNET` Variable hinzugefΓΌgt (default: mainnet)
- `website/wagmi.config.ts`: Base + Base Sepolia zu Chains hinzugefΓΌgt
-- `website/utils/getChain.ts`:
+- `website/utils/getChain.ts`:
- `VITE_USE_TESTNET` steuert Mainnet/Testnet Modus
- `SUPPORT_V2_CHAINS` exportiert aktive Chains basierend auf Modus
- `getSupportV2Config()`, `isSupportV2Chain()`, `DEFAULT_SUPPORT_CHAIN`
- Legacy `supportContractConfig` entfernt
-- `website/hooks/useSupportAction.ts`:
+- `website/hooks/useSupportAction.ts`:
- Multi-Chain mit automatischem Chain-Switch
- Aggregierte Likes von beiden Chains im aktiven Modus
- `website/test/useSupportAction.test.ts`: 17 Unit Tests
### Phase 4: Production Deployment β
ABGESCHLOSSEN
-| Schritt | Beschreibung | Status |
-|---------|--------------|--------|
-| 4.1 | Deploy auf Optimism Mainnet | β
|
-| 4.2 | Deploy auf Base Mainnet | β
|
-| 4.3 | `getChain.ts` β Mainnet Adressen eintragen | β
|
-| 4.4 | `DEFAULT_SUPPORT_CHAIN` auf Mainnet umstellen | β
|
+| Schritt | Beschreibung | Status |
+| ------- | --------------------------------------------- | ------ |
+| 4.1 | Deploy auf Optimism Mainnet | β
|
+| 4.2 | Deploy auf Base Mainnet | β
|
+| 4.3 | `getChain.ts` β Mainnet Adressen eintragen | β
|
+| 4.4 | `DEFAULT_SUPPORT_CHAIN` auf Mainnet umstellen | β
|
---
@@ -412,12 +407,12 @@ function donate(string calldata _url, address _recipient) external payable
#### Wo passiert was?
-| Aktion | Wo | Code |
-|--------|-----|------|
-| Likes lesen | `useSupportAction` Hook | `useReadContract` mit `DEFAULT_READ_CHAIN.id` |
-| Chain prΓΌfen | `handleSupport()` | `if (!isSupported)` |
-| Chain wechseln | `handleSupport()` | `await switchChainAsync({ chainId: DEFAULT_READ_CHAIN.id })` |
-| Donation senden | `handleSupport()` | `writeContract({ ...activeConfig, ... })` |
+| Aktion | Wo | Code |
+| --------------- | ----------------------- | ------------------------------------------------------------ |
+| Likes lesen | `useSupportAction` Hook | `useReadContract` mit `DEFAULT_READ_CHAIN.id` |
+| Chain prΓΌfen | `handleSupport()` | `if (!isSupported)` |
+| Chain wechseln | `handleSupport()` | `await switchChainAsync({ chainId: DEFAULT_READ_CHAIN.id })` |
+| Donation senden | `handleSupport()` | `writeContract({ ...activeConfig, ... })` |
#### Schritt 1: `wagmi.config.ts` β Base Chains hinzufΓΌgen
@@ -474,7 +469,7 @@ export const RECIPIENT_ADDRESS = "0x073f26F0C3FC100e7b075C3DC3cDE0A777497D20" as
export function getSupportV2Config(chainId: number) {
const address = SUPPORT_V2_ADDRESSES[chainId];
if (!address) return null;
-
+
return {
address,
abi: SupportV2ABI,
@@ -496,12 +491,7 @@ import * as React from "react";
import { useAccount, useWriteContract, useWaitForTransactionReceipt, useChainId, useSwitchChain } from "wagmi";
import { parseEther } from "viem";
import { useReadContract } from "wagmi";
-import {
- getSupportV2Config,
- isSupportV2Chain,
- RECIPIENT_ADDRESS,
- DEFAULT_READ_CHAIN
-} from "../utils/getChain";
+import { getSupportV2Config, isSupportV2Chain, RECIPIENT_ADDRESS, DEFAULT_READ_CHAIN } from "../utils/getChain";
import { trackEvent } from "../utils/analytics";
/**
@@ -514,10 +504,10 @@ export function useSupportAction(url: string) {
const [fullUrl, setFullUrl] = React.useState(url);
const { isConnected } = useAccount();
- const chainId = useChainId(); // β Aktuelle Chain des Users
- const { switchChainAsync } = useSwitchChain(); // β Async Version fΓΌr await
+ const chainId = useChainId(); // β Aktuelle Chain des Users
+ const { switchChainAsync } = useSwitchChain(); // β Async Version fΓΌr await
const donationAmount = parseEther("0.0002");
-
+
const { writeContract, isPending, data: hash, error: writeError } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });
@@ -533,13 +523,13 @@ export function useSupportAction(url: string) {
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// HIER WIRD GEPRΓFT: Ist die Chain unterstΓΌtzt?
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
+
// Contract Config fΓΌr aktuelle Chain holen (null wenn nicht unterstΓΌtzt)
const contractConfig = React.useMemo(() => getSupportV2Config(chainId), [chainId]);
-
+
// Boolean: Ist die aktuelle Chain unterstΓΌtzt?
const isSupported = isSupportV2Chain(chainId);
-
+
// Chain fΓΌr Read-Operationen: User's Chain wenn unterstΓΌtzt, sonst Default
const readChainId = isSupported ? chainId : DEFAULT_READ_CHAIN.id;
const readConfig = getSupportV2Config(readChainId)!;
@@ -556,7 +546,7 @@ export function useSupportAction(url: string) {
...readConfig,
functionName: "getLikesForUrl",
args: [fullUrl],
- chainId: readChainId, // β Liest von Default Chain wenn User's Chain nicht unterstΓΌtzt
+ chainId: readChainId, // β Liest von Default Chain wenn User's Chain nicht unterstΓΌtzt
query: { enabled: !!fullUrl },
});
@@ -598,7 +588,7 @@ export function useSupportAction(url: string) {
writeContract({
...activeConfig,
functionName: "donate",
- args: [fullUrl, RECIPIENT_ADDRESS], // β Neuer recipient Parameter
+ args: [fullUrl, RECIPIENT_ADDRESS], // β Neuer recipient Parameter
value: donationAmount,
});
}, [fullUrl, isSupported, chainId, switchChainAsync, writeContract, donationAmount]);
@@ -618,9 +608,9 @@ export function useSupportAction(url: string) {
}, [isSuccess, writeError, refetch, fullUrl, chainId]);
// Warning message
- const warningMessage = errorMessage || (!isSupported && isConnected
- ? `Wechsle zu ${SUPPORTED_CHAINS.map(c => c.name).join(" oder ")}`
- : null);
+ const warningMessage =
+ errorMessage ||
+ (!isSupported && isConnected ? `Wechsle zu ${SUPPORTED_CHAINS.map((c) => c.name).join(" oder ")}` : null);
return {
supportCount: supportCount?.toString() || "0",
@@ -651,7 +641,7 @@ const allCounts = await Promise.all(
args: [fullUrl],
});
return count;
- })
+ }),
);
const totalLikes = allCounts.reduce((sum, c) => sum + c, 0n);
```
diff --git a/eth/abi/contracts/CollectorNFT-summary.md b/eth/abi/contracts/CollectorNFT-summary.md
index aff967803..ab8961b3b 100644
--- a/eth/abi/contracts/CollectorNFT-summary.md
+++ b/eth/abi/contracts/CollectorNFT-summary.md
@@ -2,8 +2,6 @@
NFT collection based on GenImNFT tokens
-Generated on: 2026-01-20T20:33:28.414Z
-
## Contract Information
- **Name**: CollectorNFT
diff --git a/eth/abi/contracts/CollectorNFT.ts b/eth/abi/contracts/CollectorNFT.ts
index 1883c3f04..4859c0cd9 100644
--- a/eth/abi/contracts/CollectorNFT.ts
+++ b/eth/abi/contracts/CollectorNFT.ts
@@ -1,6 +1,5 @@
// Auto-generated ABI for CollectorNFT
// NFT collection based on GenImNFT tokens
-// Generated on: 2026-01-20T20:33:28.413Z
export const CollectorNFTABI = [
{
diff --git a/eth/abi/contracts/CollectorNFTv1-summary.md b/eth/abi/contracts/CollectorNFTv1-summary.md
index 7d00c0c20..e332634cc 100644
--- a/eth/abi/contracts/CollectorNFTv1-summary.md
+++ b/eth/abi/contracts/CollectorNFTv1-summary.md
@@ -2,8 +2,6 @@
CollectorNFT Version 1 with upgraded features and UUPS proxy pattern
-Generated on: 2026-01-20T20:33:28.415Z
-
## Contract Information
- **Name**: CollectorNFTv1
diff --git a/eth/abi/contracts/CollectorNFTv1.ts b/eth/abi/contracts/CollectorNFTv1.ts
index f812f15a5..2d02e51c3 100644
--- a/eth/abi/contracts/CollectorNFTv1.ts
+++ b/eth/abi/contracts/CollectorNFTv1.ts
@@ -1,6 +1,5 @@
// Auto-generated ABI for CollectorNFTv1
// CollectorNFT Version 1 with upgraded features and UUPS proxy pattern
-// Generated on: 2026-01-20T20:33:28.415Z
export const CollectorNFTv1ABI = [
{
diff --git a/eth/abi/contracts/EIP3009SplitterV1-summary.md b/eth/abi/contracts/EIP3009SplitterV1-summary.md
index 5c2ac2707..5970fbb36 100644
--- a/eth/abi/contracts/EIP3009SplitterV1-summary.md
+++ b/eth/abi/contracts/EIP3009SplitterV1-summary.md
@@ -2,8 +2,6 @@
EIP-3009 payment splitter with fixed facilitator fee
-Generated on: 2026-01-20T20:33:28.418Z
-
## Contract Information
- **Name**: EIP3009SplitterV1
diff --git a/eth/abi/contracts/EIP3009SplitterV1.ts b/eth/abi/contracts/EIP3009SplitterV1.ts
index d8372bd46..e5eb6b953 100644
--- a/eth/abi/contracts/EIP3009SplitterV1.ts
+++ b/eth/abi/contracts/EIP3009SplitterV1.ts
@@ -1,6 +1,5 @@
// Auto-generated ABI for EIP3009SplitterV1
// EIP-3009 payment splitter with fixed facilitator fee
-// Generated on: 2026-01-20T20:33:28.418Z
export const EIP3009SplitterV1ABI = [
{
diff --git a/eth/abi/contracts/GenImNFTv3-summary.md b/eth/abi/contracts/GenImNFTv3-summary.md
index 6b76f112e..dfdfa6166 100644
--- a/eth/abi/contracts/GenImNFTv3-summary.md
+++ b/eth/abi/contracts/GenImNFTv3-summary.md
@@ -2,8 +2,6 @@
GenImNFT Version 3 with listing functionality
-Generated on: 2026-01-20T20:33:28.408Z
-
## Contract Information
- **Name**: GenImNFTv3
diff --git a/eth/abi/contracts/GenImNFTv3.ts b/eth/abi/contracts/GenImNFTv3.ts
index 64aa58689..72195d26c 100644
--- a/eth/abi/contracts/GenImNFTv3.ts
+++ b/eth/abi/contracts/GenImNFTv3.ts
@@ -1,6 +1,5 @@
// Auto-generated ABI for GenImNFTv3
// GenImNFT Version 3 with listing functionality
-// Generated on: 2026-01-20T20:33:28.405Z
export const GenImNFTv3ABI = [
{
diff --git a/eth/abi/contracts/GenImNFTv4-summary.md b/eth/abi/contracts/GenImNFTv4-summary.md
new file mode 100644
index 000000000..d8516ef45
--- /dev/null
+++ b/eth/abi/contracts/GenImNFTv4-summary.md
@@ -0,0 +1,87 @@
+# GenImNFTv4 Contract Summary
+
+GenImNFT Version 4 with EIP-8004 agent whitelist (CVE-2025-11-26 fix)
+
+## Contract Information
+
+- **Name**: GenImNFTv4
+- **Functions**: 41
+- **Events**: 13
+- **Errors**: 20
+
+## All Functions
+
+- `UPGRADE_INTERFACE_VERSION()`
+- `approve(address to, uint256 tokenId)`
+- `authorizeAgentWallet(address agentWallet)`
+- `balanceOf(address owner)`
+- `burn(uint256 tokenId)`
+- `getAllPublicTokens()`
+- `getApproved(uint256 tokenId)`
+- `getPublicTokensOfOwner(address owner)`
+- `initialize()`
+- `isApprovedForAll(address owner, address operator)`
+- `isAuthorizedAgent(address agentWallet)`
+- `isImageUpdated(uint256 tokenId)`
+- `isTokenListed(uint256 tokenId)`
+- `mintPrice()`
+- `name()`
+- `owner()`
+- `ownerOf(uint256 tokenId)`
+- `proxiableUUID()`
+- `reinitializeV3()`
+- `reinitializeV4()`
+- `renounceOwnership()`
+- `requestImageUpdate(uint256 tokenId, string imageUrl)`
+- `revokeAgentWallet(address agentWallet)`
+- `safeMint(string uri, bool isListed)`
+- `safeMint(string uri)`
+- `safeTransferFrom(address from, address to, uint256 tokenId)`
+- `safeTransferFrom(address from, address to, uint256 tokenId, bytes data)`
+- `setApprovalForAll(address operator, bool approved)`
+- `setMintPrice(uint256 newPrice)`
+- `setMultipleTokensListed(uint256[] tokenIds, bool isListed)`
+- `setTokenListed(uint256 tokenId, bool isListed)`
+- `supportsInterface(bytes4 interfaceId)`
+- `symbol()`
+- `tokenByIndex(uint256 index)`
+- `tokenOfOwnerByIndex(address owner, uint256 index)`
+- `tokenURI(uint256 tokenId)`
+- `totalSupply()`
+- `transferFrom(address from, address to, uint256 tokenId)`
+- `transferOwnership(address newOwner)`
+- `upgradeToAndCall(address newImplementation, bytes data)`
+- `withdraw()`
+
+## Events
+
+- `AgentWalletAuthorized(indexed address agentWallet)`
+- `AgentWalletRevoked(indexed address agentWallet)`
+- `Approval(indexed address owner, indexed address approved, indexed uint256 tokenId)`
+- `ApprovalForAll(indexed address owner, indexed address operator, bool approved)`
+- `BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId)`
+- `ImageUpdateRequested(indexed uint256 tokenId, indexed address updater, string imageUrl)`
+- `Initialized(uint64 version)`
+- `MetadataUpdate(uint256 _tokenId)`
+- `OwnershipTransferred(indexed address previousOwner, indexed address newOwner)`
+- `TokenListingChanged(indexed uint256 tokenId, bool isListed)`
+- `Transfer(indexed address from, indexed address to, indexed uint256 tokenId)`
+- `UpdaterPaid(indexed uint256 tokenId, indexed address updater, uint256 amount)`
+- `Upgraded(indexed address implementation)`
+
+## Usage
+
+### TypeScript/JavaScript ES Modules
+
+```typescript
+import { GenImNFTv4ABI } from "./GenImNFTv4";
+// Use with ethers, web3, viem, etc.
+```
+
+### JSON (Direct import)
+
+```javascript
+import abi from "./GenImNFTv4.json";
+// Or for Node.js/CommonJS environments:
+const abi = require("./GenImNFTv4.json");
+```
diff --git a/eth/abi/contracts/GenImNFTv4.json b/eth/abi/contracts/GenImNFTv4.json
new file mode 100644
index 000000000..23fbd1c76
--- /dev/null
+++ b/eth/abi/contracts/GenImNFTv4.json
@@ -0,0 +1,1125 @@
+[
+ {
+ "inputs": [],
+ "stateMutability": "nonpayable",
+ "type": "constructor"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "target",
+ "type": "address"
+ }
+ ],
+ "name": "AddressEmptyCode",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "implementation",
+ "type": "address"
+ }
+ ],
+ "name": "ERC1967InvalidImplementation",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "ERC1967NonPayable",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "ERC721EnumerableForbiddenBatchMint",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "sender",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ }
+ ],
+ "name": "ERC721IncorrectOwner",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "operator",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "ERC721InsufficientApproval",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "approver",
+ "type": "address"
+ }
+ ],
+ "name": "ERC721InvalidApprover",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "operator",
+ "type": "address"
+ }
+ ],
+ "name": "ERC721InvalidOperator",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ }
+ ],
+ "name": "ERC721InvalidOwner",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "receiver",
+ "type": "address"
+ }
+ ],
+ "name": "ERC721InvalidReceiver",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "sender",
+ "type": "address"
+ }
+ ],
+ "name": "ERC721InvalidSender",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "ERC721NonexistentToken",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "index",
+ "type": "uint256"
+ }
+ ],
+ "name": "ERC721OutOfBoundsIndex",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "FailedCall",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "InvalidInitialization",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "NotInitializing",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ }
+ ],
+ "name": "OwnableInvalidOwner",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "account",
+ "type": "address"
+ }
+ ],
+ "name": "OwnableUnauthorizedAccount",
+ "type": "error"
+ },
+ {
+ "inputs": [],
+ "name": "UUPSUnauthorizedCallContext",
+ "type": "error"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "bytes32",
+ "name": "slot",
+ "type": "bytes32"
+ }
+ ],
+ "name": "UUPSUnsupportedProxiableUUID",
+ "type": "error"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "agentWallet",
+ "type": "address"
+ }
+ ],
+ "name": "AgentWalletAuthorized",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "agentWallet",
+ "type": "address"
+ }
+ ],
+ "name": "AgentWalletRevoked",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "approved",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "Approval",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "operator",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "bool",
+ "name": "approved",
+ "type": "bool"
+ }
+ ],
+ "name": "ApprovalForAll",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "_fromTokenId",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "_toTokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "BatchMetadataUpdate",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "updater",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "string",
+ "name": "imageUrl",
+ "type": "string"
+ }
+ ],
+ "name": "ImageUpdateRequested",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": false,
+ "internalType": "uint64",
+ "name": "version",
+ "type": "uint64"
+ }
+ ],
+ "name": "Initialized",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "_tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "MetadataUpdate",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "previousOwner",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "newOwner",
+ "type": "address"
+ }
+ ],
+ "name": "OwnershipTransferred",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "bool",
+ "name": "isListed",
+ "type": "bool"
+ }
+ ],
+ "name": "TokenListingChanged",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "from",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "to",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "Transfer",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "updater",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "UpdaterPaid",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "implementation",
+ "type": "address"
+ }
+ ],
+ "name": "Upgraded",
+ "type": "event"
+ },
+ {
+ "inputs": [],
+ "name": "UPGRADE_INTERFACE_VERSION",
+ "outputs": [
+ {
+ "internalType": "string",
+ "name": "",
+ "type": "string"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "to",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "approve",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "agentWallet",
+ "type": "address"
+ }
+ ],
+ "name": "authorizeAgentWallet",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ }
+ ],
+ "name": "balanceOf",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "burn",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "getAllPublicTokens",
+ "outputs": [
+ {
+ "internalType": "uint256[]",
+ "name": "",
+ "type": "uint256[]"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "getApproved",
+ "outputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ }
+ ],
+ "name": "getPublicTokensOfOwner",
+ "outputs": [
+ {
+ "internalType": "uint256[]",
+ "name": "",
+ "type": "uint256[]"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "initialize",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ },
+ {
+ "internalType": "address",
+ "name": "operator",
+ "type": "address"
+ }
+ ],
+ "name": "isApprovedForAll",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "agentWallet",
+ "type": "address"
+ }
+ ],
+ "name": "isAuthorizedAgent",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "isImageUpdated",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "isTokenListed",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "mintPrice",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "name",
+ "outputs": [
+ {
+ "internalType": "string",
+ "name": "",
+ "type": "string"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "owner",
+ "outputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "ownerOf",
+ "outputs": [
+ {
+ "internalType": "address",
+ "name": "",
+ "type": "address"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "proxiableUUID",
+ "outputs": [
+ {
+ "internalType": "bytes32",
+ "name": "",
+ "type": "bytes32"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "reinitializeV3",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "reinitializeV4",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "renounceOwnership",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "string",
+ "name": "imageUrl",
+ "type": "string"
+ }
+ ],
+ "name": "requestImageUpdate",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "agentWallet",
+ "type": "address"
+ }
+ ],
+ "name": "revokeAgentWallet",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "string",
+ "name": "uri",
+ "type": "string"
+ },
+ {
+ "internalType": "bool",
+ "name": "isListed",
+ "type": "bool"
+ }
+ ],
+ "name": "safeMint",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "payable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "string",
+ "name": "uri",
+ "type": "string"
+ }
+ ],
+ "name": "safeMint",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "payable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "from",
+ "type": "address"
+ },
+ {
+ "internalType": "address",
+ "name": "to",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "safeTransferFrom",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "from",
+ "type": "address"
+ },
+ {
+ "internalType": "address",
+ "name": "to",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "bytes",
+ "name": "data",
+ "type": "bytes"
+ }
+ ],
+ "name": "safeTransferFrom",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "operator",
+ "type": "address"
+ },
+ {
+ "internalType": "bool",
+ "name": "approved",
+ "type": "bool"
+ }
+ ],
+ "name": "setApprovalForAll",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "newPrice",
+ "type": "uint256"
+ }
+ ],
+ "name": "setMintPrice",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256[]",
+ "name": "tokenIds",
+ "type": "uint256[]"
+ },
+ {
+ "internalType": "bool",
+ "name": "isListed",
+ "type": "bool"
+ }
+ ],
+ "name": "setMultipleTokensListed",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ },
+ {
+ "internalType": "bool",
+ "name": "isListed",
+ "type": "bool"
+ }
+ ],
+ "name": "setTokenListed",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "bytes4",
+ "name": "interfaceId",
+ "type": "bytes4"
+ }
+ ],
+ "name": "supportsInterface",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "symbol",
+ "outputs": [
+ {
+ "internalType": "string",
+ "name": "",
+ "type": "string"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "index",
+ "type": "uint256"
+ }
+ ],
+ "name": "tokenByIndex",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "owner",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "index",
+ "type": "uint256"
+ }
+ ],
+ "name": "tokenOfOwnerByIndex",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "tokenURI",
+ "outputs": [
+ {
+ "internalType": "string",
+ "name": "",
+ "type": "string"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "totalSupply",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "from",
+ "type": "address"
+ },
+ {
+ "internalType": "address",
+ "name": "to",
+ "type": "address"
+ },
+ {
+ "internalType": "uint256",
+ "name": "tokenId",
+ "type": "uint256"
+ }
+ ],
+ "name": "transferFrom",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "newOwner",
+ "type": "address"
+ }
+ ],
+ "name": "transferOwnership",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "newImplementation",
+ "type": "address"
+ },
+ {
+ "internalType": "bytes",
+ "name": "data",
+ "type": "bytes"
+ }
+ ],
+ "name": "upgradeToAndCall",
+ "outputs": [],
+ "stateMutability": "payable",
+ "type": "function"
+ },
+ {
+ "inputs": [],
+ "name": "withdraw",
+ "outputs": [],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ }
+]
diff --git a/eth/abi/contracts/GenImNFTv4.ts b/eth/abi/contracts/GenImNFTv4.ts
new file mode 100644
index 000000000..cd682a7ad
--- /dev/null
+++ b/eth/abi/contracts/GenImNFTv4.ts
@@ -0,0 +1,1130 @@
+// Auto-generated ABI for GenImNFTv4
+// GenImNFT Version 4 with EIP-8004 agent whitelist (CVE-2025-11-26 fix)
+
+export const GenImNFTv4ABI = [
+ {
+ inputs: [],
+ stateMutability: "nonpayable",
+ type: "constructor",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "target",
+ type: "address",
+ },
+ ],
+ name: "AddressEmptyCode",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "implementation",
+ type: "address",
+ },
+ ],
+ name: "ERC1967InvalidImplementation",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "ERC1967NonPayable",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "ERC721EnumerableForbiddenBatchMint",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "sender",
+ type: "address",
+ },
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ {
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ ],
+ name: "ERC721IncorrectOwner",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "operator",
+ type: "address",
+ },
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "ERC721InsufficientApproval",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "approver",
+ type: "address",
+ },
+ ],
+ name: "ERC721InvalidApprover",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "operator",
+ type: "address",
+ },
+ ],
+ name: "ERC721InvalidOperator",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ ],
+ name: "ERC721InvalidOwner",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "receiver",
+ type: "address",
+ },
+ ],
+ name: "ERC721InvalidReceiver",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "sender",
+ type: "address",
+ },
+ ],
+ name: "ERC721InvalidSender",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "ERC721NonexistentToken",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ {
+ internalType: "uint256",
+ name: "index",
+ type: "uint256",
+ },
+ ],
+ name: "ERC721OutOfBoundsIndex",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "FailedCall",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "InvalidInitialization",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "NotInitializing",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ ],
+ name: "OwnableInvalidOwner",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "account",
+ type: "address",
+ },
+ ],
+ name: "OwnableUnauthorizedAccount",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "UUPSUnauthorizedCallContext",
+ type: "error",
+ },
+ {
+ inputs: [
+ {
+ internalType: "bytes32",
+ name: "slot",
+ type: "bytes32",
+ },
+ ],
+ name: "UUPSUnsupportedProxiableUUID",
+ type: "error",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "address",
+ name: "agentWallet",
+ type: "address",
+ },
+ ],
+ name: "AgentWalletAuthorized",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "address",
+ name: "agentWallet",
+ type: "address",
+ },
+ ],
+ name: "AgentWalletRevoked",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ {
+ indexed: true,
+ internalType: "address",
+ name: "approved",
+ type: "address",
+ },
+ {
+ indexed: true,
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "Approval",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ {
+ indexed: true,
+ internalType: "address",
+ name: "operator",
+ type: "address",
+ },
+ {
+ indexed: false,
+ internalType: "bool",
+ name: "approved",
+ type: "bool",
+ },
+ ],
+ name: "ApprovalForAll",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: false,
+ internalType: "uint256",
+ name: "_fromTokenId",
+ type: "uint256",
+ },
+ {
+ indexed: false,
+ internalType: "uint256",
+ name: "_toTokenId",
+ type: "uint256",
+ },
+ ],
+ name: "BatchMetadataUpdate",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ {
+ indexed: true,
+ internalType: "address",
+ name: "updater",
+ type: "address",
+ },
+ {
+ indexed: false,
+ internalType: "string",
+ name: "imageUrl",
+ type: "string",
+ },
+ ],
+ name: "ImageUpdateRequested",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: false,
+ internalType: "uint64",
+ name: "version",
+ type: "uint64",
+ },
+ ],
+ name: "Initialized",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: false,
+ internalType: "uint256",
+ name: "_tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "MetadataUpdate",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "address",
+ name: "previousOwner",
+ type: "address",
+ },
+ {
+ indexed: true,
+ internalType: "address",
+ name: "newOwner",
+ type: "address",
+ },
+ ],
+ name: "OwnershipTransferred",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ {
+ indexed: false,
+ internalType: "bool",
+ name: "isListed",
+ type: "bool",
+ },
+ ],
+ name: "TokenListingChanged",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "address",
+ name: "from",
+ type: "address",
+ },
+ {
+ indexed: true,
+ internalType: "address",
+ name: "to",
+ type: "address",
+ },
+ {
+ indexed: true,
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "Transfer",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ {
+ indexed: true,
+ internalType: "address",
+ name: "updater",
+ type: "address",
+ },
+ {
+ indexed: false,
+ internalType: "uint256",
+ name: "amount",
+ type: "uint256",
+ },
+ ],
+ name: "UpdaterPaid",
+ type: "event",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "address",
+ name: "implementation",
+ type: "address",
+ },
+ ],
+ name: "Upgraded",
+ type: "event",
+ },
+ {
+ inputs: [],
+ name: "UPGRADE_INTERFACE_VERSION",
+ outputs: [
+ {
+ internalType: "string",
+ name: "",
+ type: "string",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "to",
+ type: "address",
+ },
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "approve",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "agentWallet",
+ type: "address",
+ },
+ ],
+ name: "authorizeAgentWallet",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ ],
+ name: "balanceOf",
+ outputs: [
+ {
+ internalType: "uint256",
+ name: "",
+ type: "uint256",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "burn",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "getAllPublicTokens",
+ outputs: [
+ {
+ internalType: "uint256[]",
+ name: "",
+ type: "uint256[]",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "getApproved",
+ outputs: [
+ {
+ internalType: "address",
+ name: "",
+ type: "address",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ ],
+ name: "getPublicTokensOfOwner",
+ outputs: [
+ {
+ internalType: "uint256[]",
+ name: "",
+ type: "uint256[]",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "initialize",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ {
+ internalType: "address",
+ name: "operator",
+ type: "address",
+ },
+ ],
+ name: "isApprovedForAll",
+ outputs: [
+ {
+ internalType: "bool",
+ name: "",
+ type: "bool",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "agentWallet",
+ type: "address",
+ },
+ ],
+ name: "isAuthorizedAgent",
+ outputs: [
+ {
+ internalType: "bool",
+ name: "",
+ type: "bool",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "isImageUpdated",
+ outputs: [
+ {
+ internalType: "bool",
+ name: "",
+ type: "bool",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "isTokenListed",
+ outputs: [
+ {
+ internalType: "bool",
+ name: "",
+ type: "bool",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "mintPrice",
+ outputs: [
+ {
+ internalType: "uint256",
+ name: "",
+ type: "uint256",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "name",
+ outputs: [
+ {
+ internalType: "string",
+ name: "",
+ type: "string",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "owner",
+ outputs: [
+ {
+ internalType: "address",
+ name: "",
+ type: "address",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "ownerOf",
+ outputs: [
+ {
+ internalType: "address",
+ name: "",
+ type: "address",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "proxiableUUID",
+ outputs: [
+ {
+ internalType: "bytes32",
+ name: "",
+ type: "bytes32",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "reinitializeV3",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "reinitializeV4",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "renounceOwnership",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ {
+ internalType: "string",
+ name: "imageUrl",
+ type: "string",
+ },
+ ],
+ name: "requestImageUpdate",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "agentWallet",
+ type: "address",
+ },
+ ],
+ name: "revokeAgentWallet",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "string",
+ name: "uri",
+ type: "string",
+ },
+ {
+ internalType: "bool",
+ name: "isListed",
+ type: "bool",
+ },
+ ],
+ name: "safeMint",
+ outputs: [
+ {
+ internalType: "uint256",
+ name: "",
+ type: "uint256",
+ },
+ ],
+ stateMutability: "payable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "string",
+ name: "uri",
+ type: "string",
+ },
+ ],
+ name: "safeMint",
+ outputs: [
+ {
+ internalType: "uint256",
+ name: "",
+ type: "uint256",
+ },
+ ],
+ stateMutability: "payable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "from",
+ type: "address",
+ },
+ {
+ internalType: "address",
+ name: "to",
+ type: "address",
+ },
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "safeTransferFrom",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "from",
+ type: "address",
+ },
+ {
+ internalType: "address",
+ name: "to",
+ type: "address",
+ },
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ {
+ internalType: "bytes",
+ name: "data",
+ type: "bytes",
+ },
+ ],
+ name: "safeTransferFrom",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "operator",
+ type: "address",
+ },
+ {
+ internalType: "bool",
+ name: "approved",
+ type: "bool",
+ },
+ ],
+ name: "setApprovalForAll",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "newPrice",
+ type: "uint256",
+ },
+ ],
+ name: "setMintPrice",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256[]",
+ name: "tokenIds",
+ type: "uint256[]",
+ },
+ {
+ internalType: "bool",
+ name: "isListed",
+ type: "bool",
+ },
+ ],
+ name: "setMultipleTokensListed",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ {
+ internalType: "bool",
+ name: "isListed",
+ type: "bool",
+ },
+ ],
+ name: "setTokenListed",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "bytes4",
+ name: "interfaceId",
+ type: "bytes4",
+ },
+ ],
+ name: "supportsInterface",
+ outputs: [
+ {
+ internalType: "bool",
+ name: "",
+ type: "bool",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "symbol",
+ outputs: [
+ {
+ internalType: "string",
+ name: "",
+ type: "string",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "index",
+ type: "uint256",
+ },
+ ],
+ name: "tokenByIndex",
+ outputs: [
+ {
+ internalType: "uint256",
+ name: "",
+ type: "uint256",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "owner",
+ type: "address",
+ },
+ {
+ internalType: "uint256",
+ name: "index",
+ type: "uint256",
+ },
+ ],
+ name: "tokenOfOwnerByIndex",
+ outputs: [
+ {
+ internalType: "uint256",
+ name: "",
+ type: "uint256",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "tokenURI",
+ outputs: [
+ {
+ internalType: "string",
+ name: "",
+ type: "string",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "totalSupply",
+ outputs: [
+ {
+ internalType: "uint256",
+ name: "",
+ type: "uint256",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "from",
+ type: "address",
+ },
+ {
+ internalType: "address",
+ name: "to",
+ type: "address",
+ },
+ {
+ internalType: "uint256",
+ name: "tokenId",
+ type: "uint256",
+ },
+ ],
+ name: "transferFrom",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "newOwner",
+ type: "address",
+ },
+ ],
+ name: "transferOwnership",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "newImplementation",
+ type: "address",
+ },
+ {
+ internalType: "bytes",
+ name: "data",
+ type: "bytes",
+ },
+ ],
+ name: "upgradeToAndCall",
+ outputs: [],
+ stateMutability: "payable",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "withdraw",
+ outputs: [],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+] as const;
+
+export type GenImNFTv4ABI = typeof GenImNFTv4ABI;
diff --git a/eth/abi/contracts/LLMv1-summary.md b/eth/abi/contracts/LLMv1-summary.md
index 487253bcd..c7aebc153 100644
--- a/eth/abi/contracts/LLMv1-summary.md
+++ b/eth/abi/contracts/LLMv1-summary.md
@@ -2,8 +2,6 @@
LLM Version 1 to interact with LLMs
-Generated on: 2026-01-20T20:33:28.417Z
-
## Contract Information
- **Name**: LLMv1
diff --git a/eth/abi/contracts/LLMv1.ts b/eth/abi/contracts/LLMv1.ts
index 64db0eb45..7faec138e 100644
--- a/eth/abi/contracts/LLMv1.ts
+++ b/eth/abi/contracts/LLMv1.ts
@@ -1,6 +1,5 @@
// Auto-generated ABI for LLMv1
// LLM Version 1 to interact with LLMs
-// Generated on: 2026-01-20T20:33:28.417Z
export const LLMv1ABI = [
{
diff --git a/eth/abi/contracts/Support-summary.md b/eth/abi/contracts/Support-summary.md
index 3ab286738..7cf07d2ec 100644
--- a/eth/abi/contracts/Support-summary.md
+++ b/eth/abi/contracts/Support-summary.md
@@ -2,8 +2,6 @@
Support contract for donations and likes functionality
-Generated on: 2026-01-20T20:33:28.416Z
-
## Contract Information
- **Name**: Support
diff --git a/eth/abi/contracts/Support.ts b/eth/abi/contracts/Support.ts
index 9ca345c0e..61bdc9411 100644
--- a/eth/abi/contracts/Support.ts
+++ b/eth/abi/contracts/Support.ts
@@ -1,6 +1,5 @@
// Auto-generated ABI for Support
// Support contract for donations and likes functionality
-// Generated on: 2026-01-20T20:33:28.416Z
export const SupportABI = [
{
diff --git a/eth/abi/contracts/SupportV2-summary.md b/eth/abi/contracts/SupportV2-summary.md
index 4251cce17..4838d03c8 100644
--- a/eth/abi/contracts/SupportV2-summary.md
+++ b/eth/abi/contracts/SupportV2-summary.md
@@ -2,8 +2,6 @@
Support contract V2 with ETH and EIP-3009 token donations
-Generated on: 2026-01-20T20:33:28.419Z
-
## Contract Information
- **Name**: SupportV2
diff --git a/eth/abi/contracts/SupportV2.ts b/eth/abi/contracts/SupportV2.ts
index fef4ed318..0676ae90e 100644
--- a/eth/abi/contracts/SupportV2.ts
+++ b/eth/abi/contracts/SupportV2.ts
@@ -1,6 +1,5 @@
// Auto-generated ABI for SupportV2
// Support contract V2 with ETH and EIP-3009 token donations
-// Generated on: 2026-01-20T20:33:28.419Z
export const SupportV2ABI = [
{
diff --git a/eth/deployments/support-v2-optimisticEthereum.json b/eth/deployments/support-v2-optimisticEthereum.json
index 345fa372e..7bc2f4cf1 100644
--- a/eth/deployments/support-v2-optimisticEthereum.json
+++ b/eth/deployments/support-v2-optimisticEthereum.json
@@ -5,4 +5,4 @@
"owner": "0x073f26F0C3FC100e7b075C3DC3cDE0A777497D20",
"deployedAt": "2026-01-24T17:05:07.947Z",
"version": "1.0.0"
-}
\ No newline at end of file
+}
diff --git a/eth/deployments/support-v2-optsepolia.json b/eth/deployments/support-v2-optsepolia.json
index 1c9c009db..213863795 100644
--- a/eth/deployments/support-v2-optsepolia.json
+++ b/eth/deployments/support-v2-optsepolia.json
@@ -5,4 +5,4 @@
"owner": "0x073f26F0C3FC100e7b075C3DC3cDE0A777497D20",
"deployedAt": "2026-01-20T20:31:42.392Z",
"version": "1.0.0"
-}
\ No newline at end of file
+}
diff --git a/eth/scripts/deploy-support-v2.config.json b/eth/scripts/deploy-support-v2.config.json
index 570ecae80..c0ba7ecd8 100644
--- a/eth/scripts/deploy-support-v2.config.json
+++ b/eth/scripts/deploy-support-v2.config.json
@@ -11,4 +11,4 @@
"version": "1.0.0",
"environment": "production"
}
-}
\ No newline at end of file
+}
diff --git a/eth/scripts/export-abi.ts b/eth/scripts/export-abi.ts
index a73cd0d87..b5114d26e 100644
--- a/eth/scripts/export-abi.ts
+++ b/eth/scripts/export-abi.ts
@@ -31,6 +31,12 @@ const contracts: ContractConfig[] = [
contractName: "GenImNFTv3",
description: "GenImNFT Version 3 with listing functionality",
},
+ {
+ name: "GenImNFTv4",
+ contractFile: "GenImNFTv4.sol",
+ contractName: "GenImNFTv4",
+ description: "GenImNFT Version 4 with EIP-8004 agent whitelist (CVE-2025-11-26 fix)",
+ },
{
name: "CollectorNFT",
contractFile: "CollectorNFT.sol",
@@ -102,7 +108,6 @@ async function exportContractABI(config: ContractConfig) {
const abiTsPath = path.join(exportDir, `${config.name}.ts`);
const tsContent = `// Auto-generated ABI for ${config.name}
// ${config.description}
-// Generated on: ${new Date().toISOString()}
export const ${config.name}ABI = ${JSON.stringify(abi, null, 2)} as const;
@@ -171,8 +176,6 @@ export type ${config.name}ABI = typeof ${config.name}ABI;
${config.description}
-Generated on: ${new Date().toISOString()}
-
## Contract Information
- **Name**: ${config.name}
- **Functions**: ${functions.length}
diff --git a/eth/test/SupportV2_Deployment.ts b/eth/test/SupportV2_Deployment.ts
index b3e700be8..ab6a6518d 100644
--- a/eth/test/SupportV2_Deployment.ts
+++ b/eth/test/SupportV2_Deployment.ts
@@ -54,11 +54,7 @@ describe("SupportV2 - Deployment Tests", function () {
/**
* Helper: Run test with temporary config, then restore original
*/
- async function withTempConfig(
- options: SupportV2ConfigOptions,
- testFn: () => Promise,
- owner?: string,
- ) {
+ async function withTempConfig(options: SupportV2ConfigOptions, testFn: () => Promise, owner?: string) {
await createTempConfig(options, owner);
try {
@@ -177,10 +173,7 @@ describe("SupportV2 - Deployment Tests", function () {
describe("Configuration Validation", function () {
it("should reject invalid owner address format", async function () {
- const invalidConfigPath = path.join(
- __dirname,
- "../scripts/deploy-support-v2.config-invalid.json",
- );
+ const invalidConfigPath = path.join(__dirname, "../scripts/deploy-support-v2.config-invalid.json");
const invalidConfig = {
parameters: {
@@ -277,16 +270,10 @@ describe("SupportV2 - Deployment Tests", function () {
expect(result).to.not.be.undefined;
// Verify proxy implementation slot is set
- const implementationSlot =
- "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
- const implementation = await hre.ethers.provider.getStorage(
- result!.proxyAddress,
- implementationSlot,
- );
-
- expect(implementation).to.not.equal(
- "0x0000000000000000000000000000000000000000000000000000000000000000",
- );
+ const implementationSlot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
+ const implementation = await hre.ethers.provider.getStorage(result!.proxyAddress, implementationSlot);
+
+ expect(implementation).to.not.equal("0x0000000000000000000000000000000000000000000000000000000000000000");
});
});
diff --git a/shared/chain-utils/src/abi/GenImNFTv4.ts b/shared/chain-utils/src/abi/GenImNFTv4.ts
index d4a17f256..ec7a1bb46 100644
--- a/shared/chain-utils/src/abi/GenImNFTv4.ts
+++ b/shared/chain-utils/src/abi/GenImNFTv4.ts
@@ -1,7 +1,7 @@
/**
* GenImNFTv4 Minimal ABI
*
- * Contains only the functions used by scw_js and x402_facilitator.
+ * Contains the functions used by scw_js, x402_facilitator, and website frontend.
* Full ABI available in: eth/abi/contracts/GenImNFTv4.ts (after export)
*
* Used by:
@@ -9,6 +9,9 @@
* - scw_js/genimg_x402_token.js: safeMint, safeTransferFrom, mintPrice
* - scw_js/readhandler_v2.js: ownerOf, requestImageUpdate, mintPrice, isImageUpdated
* - x402_facilitator/x402_whitelist.js: isAuthorizedAgent
+ * - website/components/PublicNFTList.tsx: getAllPublicTokens
+ * - website/components/MyNFTList.tsx: balanceOf, tokenOfOwnerByIndex
+ * - website/components/NFTCard.tsx: setTokenListed, isTokenListed, burn
*/
export const GenImNFTv4ABI = [
@@ -52,12 +55,53 @@ export const GenImNFTv4ABI = [
outputs: [{ name: "", type: "string" }],
},
{
- name: "isListed",
+ name: "isTokenListed",
type: "function",
stateMutability: "view",
inputs: [{ name: "tokenId", type: "uint256" }],
outputs: [{ name: "", type: "bool" }],
},
+ {
+ name: "getAllPublicTokens",
+ type: "function",
+ stateMutability: "view",
+ inputs: [],
+ outputs: [{ name: "", type: "uint256[]" }],
+ },
+ {
+ name: "balanceOf",
+ type: "function",
+ stateMutability: "view",
+ inputs: [{ name: "owner", type: "address" }],
+ outputs: [{ name: "", type: "uint256" }],
+ },
+ {
+ name: "tokenOfOwnerByIndex",
+ type: "function",
+ stateMutability: "view",
+ inputs: [
+ { name: "owner", type: "address" },
+ { name: "index", type: "uint256" },
+ ],
+ outputs: [{ name: "", type: "uint256" }],
+ },
+ {
+ name: "setTokenListed",
+ type: "function",
+ stateMutability: "nonpayable",
+ inputs: [
+ { name: "tokenId", type: "uint256" },
+ { name: "_isListed", type: "bool" },
+ ],
+ outputs: [],
+ },
+ {
+ name: "burn",
+ type: "function",
+ stateMutability: "nonpayable",
+ inputs: [{ name: "tokenId", type: "uint256" }],
+ outputs: [],
+ },
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Write Functions
diff --git a/shared/chain-utils/src/addresses.ts b/shared/chain-utils/src/addresses.ts
index d406e7d80..fee1dace5 100644
--- a/shared/chain-utils/src/addresses.ts
+++ b/shared/chain-utils/src/addresses.ts
@@ -74,6 +74,12 @@ export const TESTNET_LLM_V1_ADDRESSES: Record = {
"eip155:11155420": "0xB3dbD44477a7bcf253f2fA68eDb4be5aF2F2cA56", // Optimism Sepolia
};
+/** All networks where LLMv1 is deployed */
+export const LLM_V1_NETWORKS = [
+ ...Object.keys(MAINNET_LLM_V1_ADDRESSES),
+ ...Object.keys(TESTNET_LLM_V1_ADDRESSES),
+] as const;
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// USDC (available on all chains)
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
diff --git a/website/MULTICHAIN_EXPANSION_PROPOSAL.md b/website/MULTICHAIN_EXPANSION_PROPOSAL.md
index 4a3394516..b134158f8 100644
--- a/website/MULTICHAIN_EXPANSION_PROPOSAL.md
+++ b/website/MULTICHAIN_EXPANSION_PROPOSAL.md
@@ -20,8 +20,8 @@
|-------|-----|----------|--------|
| **1a** | `@fretchen/chain-utils` erstellen | shared/ | β
Fertig |
| **1b** | scw_js auf chain-utils migrieren | scw_js/ | β
Fertig |
-| **1c** | x402_facilitator auf chain-utils migrieren | x402_facilitator/ | β¬ Next |
-| **2** | GenImNFT-Komponenten migrieren | website/ | β¬ |
+| **1c** | x402_facilitator auf chain-utils migrieren | x402_facilitator/ | β
Fertig |
+| **2** | GenImNFT-Komponenten migrieren | website/ | β¬ Next |
| **3** | CollectorNFT-Komponenten migrieren | website/ | β¬ |
| **4** | GenImNFTv4 auf Base deployen | eth/, shared/ | β¬ SpΓ€ter |
| **5** | CollectorNFTv1 auf Base deployen | eth/, shared/ | β¬ SpΓ€ter |
@@ -86,16 +86,24 @@ fretchen.github.io/
---
-## Phase 1c: x402_facilitator Migration β¬ NEXT
+## Phase 1c: x402_facilitator Migration β
FERTIG
-**Geplant:**
-- `chain_utils.js` durch Imports aus `@fretchen/chain-utils` ersetzen
-- tsup Bundling hinzufΓΌgen (gleiches Pattern wie scw_js)
-- ABIs aus chain-utils importieren {
- "@fretchen/chain-utils": "file:../shared/chain-utils"
- }
-}
-```
+**Γnderungen:**
+- `package.json`: Dependency `"@fretchen/chain-utils": "file:../shared/chain-utils"`
+- `tsup.config.js`: Bundling mit tsup fΓΌr Scaleway Deployment
+- `chain_utils.js`: Nutzt `getViemChain()`, `tryGetGenAiNFTAddress()`, `tryGetLLMv1Address()`, `getUSDCAddress()`, `getUSDCName()` aus chain-utils
+- `x402_splitter_verify.js`: Importiert `getViemChain`, `getUSDCName`, `getUSDCAddress`, `getEIP3009SplitterAddress` aus chain-utils
+- `x402_splitter_settle.js`: Importiert `EIP3009SplitterV1ABI`, `getEIP3009SplitterAddress`, `getViemChain`, `getUSDCAddress` aus chain-utils
+- `x402_whitelist.js`: Nutzt `getChainConfig()` fΓΌr Contract-Adressen
+- `facilitator_instance.js`: Nutzt `getSupportedNetworks()` aus chain_utils.js
+
+**UnterstΓΌtzte Netzwerke:**
+- Optimism Mainnet (`eip155:10`)
+- Optimism Sepolia (`eip155:11155420`)
+- Base Mainnet (`eip155:8453`)
+- Base Sepolia (`eip155:84532`)
+
+**Tests:** 153 Tests bestanden, 73.47% Coverage
---
@@ -165,37 +173,294 @@ export function getChainConfig(network) {
---
-## Phase 2: GenImNFT-Komponenten migrieren (Website)
+## Phase 2: GenImNFT Website Components Migration β¬ NEXT
+
+This phase performs a **clean break** from the existing `utils/getChain.ts` to consistently use `@fretchen/chain-utils`. The goal is simplicity over backward compatibility.
+
+### Implementation Strategy: Two PRs
+
+Phase 2 is split into two PRs to minimize risk and allow staged deployment:
+
+| PR | Name | Content | Breaking? | Deployable? |
+|----|------|---------|-----------|-------------|
+| **2a** | Add chain-utils infrastructure | Dependency + `useAutoNetwork` hook + re-exports (keep old) | β No | β
Yes |
+| **2b** | Migrate GenImNFT components | All component migrations + remove old exports | β οΈ Yes | β
Yes |
+
+**Why two PRs:**
+1. **PR 2a is low-risk:** Adds foundation without changing behavior. Verifiable via build + tests.
+2. **PR 2b is atomic:** Components are tightly coupled β can't have half on old API, half on new.
+3. **Clear rollback point:** If PR 2b causes issues, revert to PR 2a state.
+
+---
+
+### PR 2a: Add chain-utils Infrastructure
+
+**Files to create/modify:**
+- `package.json` β Add @fretchen/chain-utils dependency
+- `hooks/useAutoNetwork.ts` β **CREATE NEW**
+- `utils/getChain.ts` β Add re-exports (keep old exports)
+- `utils/nodeChainUtils.ts` β Add `getDefaultNetwork()`
+
+**Acceptance criteria:**
+- [ ] `npm run build` passes
+- [ ] `npm test` passes
+- [ ] No behavior change in production
+
+---
+
+### PR 2b: Migrate GenImNFT Components
+
+**Files to modify:**
+- `utils/getChain.ts` β Remove old exports
+- `utils/nftLoader.ts` β Use chain-utils
+- `utils/nodeNftLoader.ts` β Use chain-utils
+- `components/MyNFTList.tsx` β Use `useAutoNetwork()`
+- `components/NFTCard.tsx` β Use `useAutoNetwork()` + `getGenAiNFTAddress()`
+- `components/NFTList.tsx` β Add network prop
+- `components/PublicNFTList.tsx` β Add network prop
+- `components/EntryNftImage.tsx` β Use `getDefaultNetwork()` for SSR
+- `components/NFTFloatImage.tsx` β Update to use network
+- `components/ImageGenerator.tsx` β Remove hardcoded chain ID
+- `components/AgentInfoPanel.tsx` β Use `useAutoNetwork()`
+- `test/*.test.tsx` β Update mocks
+
+**Acceptance criteria:**
+- [ ] `npm run build` passes
+- [ ] `npm test` passes
+- [ ] Manual test: Connect wallet on Optimism β NFTs load
+- [ ] Manual test: Connect wallet on unsupported chain β Auto-switches to Optimism
+
+---
+
+### Step 0: Add chain-utils Dependency
+
+**File:** `website/package.json`
+
+```bash
+npm install @fretchen/chain-utils@file:../shared/chain-utils
+```
+
+### Step 1: Create `useAutoNetwork` Hook (NEW)
+
+**File:** `website/hooks/useAutoNetwork.ts`
+
+This hook replaces the scattered chain detection logic with a centralized, reusable pattern.
+
+**Behavior:**
+- Detects user's connected wallet chain
+- If chain is in `supportedNetworks` β return CAIP-2 network string
+- If chain is NOT supported β automatically switch to default chain (first in list)
+- If no wallet connected β return default network
+
+```typescript
+import { useAccount, useChainId, useSwitchChain } from "wagmi";
+import { useEffect, useMemo } from "react";
+import { toCAIP2, fromCAIP2 } from "@fretchen/chain-utils";
+
+/**
+ * Returns the current CAIP-2 network if supported, otherwise switches to default.
+ * @param supportedNetworks - Array of CAIP-2 network strings (e.g., ["eip155:10", "eip155:11155420"])
+ * @returns Current CAIP-2 network string
+ */
+export function useAutoNetwork(supportedNetworks: string[]): string {
+ const chainId = useChainId();
+ const { isConnected } = useAccount();
+ const { switchChain } = useSwitchChain();
+
+ const defaultNetwork = supportedNetworks[0];
+ const currentNetwork = toCAIP2(chainId);
+ const isSupported = supportedNetworks.includes(currentNetwork);
+
+ useEffect(() => {
+ if (isConnected && !isSupported && switchChain) {
+ switchChain({ chainId: fromCAIP2(defaultNetwork) });
+ }
+ }, [isConnected, isSupported, switchChain, defaultNetwork]);
+
+ return isSupported ? currentNetwork : defaultNetwork;
+}
+```
+
+### Step 2: Replace `utils/getChain.ts` (BREAKING CHANGE)
+
+**File:** `website/utils/getChain.ts`
+
+Delete all legacy code and replace with thin re-exports from chain-utils. This is a **clean break** - components must be updated to use the new pattern.
+
+```typescript
+// Re-export everything from chain-utils for convenience
+export {
+ toCAIP2,
+ fromCAIP2,
+ getViemChain,
+ isMainnet,
+ isTestnet,
+ getGenAiNFTAddress,
+ getCollectorNFTAddress,
+ GenImNFTv4ABI,
+ GENAI_NFT_NETWORKS,
+ COLLECTOR_NFT_NETWORKS,
+} from "@fretchen/chain-utils";
+
+// REMOVED: getChain(), genAiNFTContractConfig, collectorNFTContractConfig
+// Use useAutoNetwork() + getGenAiNFTAddress(network) instead
+```
+
+### Step 3: Update `utils/nodeChainUtils.ts` (Server-Side)
+
+**File:** `website/utils/nodeChainUtils.ts`
+
+For SSR/server-side rendering, we need a non-hook version:
+
+```typescript
+import {
+ getViemChain,
+ getGenAiNFTAddress,
+ isMainnet
+} from "@fretchen/chain-utils";
+
+/**
+ * Get default network for server-side rendering.
+ * Uses mainnet for production, testnet for development.
+ */
+export function getDefaultNetwork(): string {
+ const isProd = process.env.NODE_ENV === "production";
+ return isProd ? "eip155:10" : "eip155:11155420";
+}
+
+// Re-export chain-utils functions for server use
+export { getViemChain, getGenAiNFTAddress, isMainnet };
+```
+
+### Step 4: Update `utils/nftLoader.ts` (Viem-only)
+
+**File:** `website/utils/nftLoader.ts`
+
+```typescript
+// BEFORE
+import { getChain, genAiNFTContractConfig } from "./getChain";
+const chain = getChain();
+
+// AFTER
+import { getViemChain, getGenAiNFTAddress, GenImNFTv4ABI } from "@fretchen/chain-utils";
+
+export async function loadNFT(network: string, tokenId: bigint) {
+ const chain = getViemChain(network);
+ const client = createPublicClient({ chain, transport: http() });
+
+ return client.readContract({
+ address: getGenAiNFTAddress(network),
+ abi: GenImNFTv4ABI,
+ functionName: "tokenURI",
+ args: [tokenId],
+ });
+}
+```
+
+### Step 5: Update `utils/nodeNftLoader.ts` (SSR)
+
+**File:** `website/utils/nodeNftLoader.ts`
+
+Same pattern as nftLoader.ts but with explicit network parameter for SSR context.
+
+### Step 6: Migrate Components (8 files)
+
+Each component follows the same pattern:
+
+| File | Changes |
+|------|---------|
+| `components/MyNFTList.tsx` | Replace `getChain()` with `useAutoNetwork(GENAI_NFT_NETWORKS)` |
+| `components/NFTCard.tsx` | Replace `genAiNFTContractConfig` with `getGenAiNFTAddress(network)` + ABI |
+| `components/NFTList.tsx` | Add network parameter, use chain-utils |
+| `components/PublicNFTList.tsx` | Add network parameter |
+| `components/EntryNftImage.tsx` | Use `getDefaultNetwork()` for SSR |
+| `components/NFTFloatImage.tsx` | Update to use network prop |
+| `components/ImageGenerator.tsx` | **Remove hardcoded `11155420` check**, use `isTestnet(network)` |
+| `components/AgentInfoPanel.tsx` | Use `useAutoNetwork()` |
+
+**Example Migration (NFTCard.tsx):**
-**Betroffene Dateien:**
-- `MyNFTList.tsx` (4 Stellen)
-- `NFTCard.tsx` (8 Stellen)
-- `NFTList.tsx` (1 Stelle)
-- `EntryNftImage.tsx` (2 Stellen)
-- `PublicNFTList.tsx` (1 Stelle)
-- `nftLoader.ts` (2 Stellen)
-
-**Pattern-Γnderung:**
```tsx
-// VORHER
+// BEFORE
import { getChain, genAiNFTContractConfig } from "../utils/getChain";
-const chain = getChain();
-const { data } = useReadContract({
- ...genAiNFTContractConfig,
- chainId: chain.id,
-});
-// NACHHER
+function NFTCard({ tokenId }) {
+ const chain = getChain();
+ const { data } = useReadContract({
+ ...genAiNFTContractConfig,
+ chainId: chain.id,
+ functionName: "tokenURI",
+ args: [tokenId],
+ });
+}
+
+// AFTER
import { useAutoNetwork } from "../hooks/useAutoNetwork";
-import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils";
+import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS, fromCAIP2 } from "@fretchen/chain-utils";
-const network = useAutoNetwork(GENAI_NFT_NETWORKS);
-const { data } = useReadContract({
- address: getGenAiNFTAddress(network),
- abi: GenImNFTv4ABI,
-});
+function NFTCard({ tokenId }) {
+ const network = useAutoNetwork(GENAI_NFT_NETWORKS);
+ const { data } = useReadContract({
+ address: getGenAiNFTAddress(network),
+ abi: GenImNFTv4ABI,
+ chainId: fromCAIP2(network),
+ functionName: "tokenURI",
+ args: [tokenId],
+ });
+}
```
+### Step 7: Update Tests
+
+**Files:**
+- `test/ContractChainSelection.test.ts`
+- `test/nftLoader.test.ts` (if exists)
+
+Update tests to use CAIP-2 networks and mock `useAutoNetwork`:
+
+```typescript
+import { vi } from "vitest";
+
+vi.mock("../hooks/useAutoNetwork", () => ({
+ useAutoNetwork: () => "eip155:11155420", // Mock testnet
+}));
+```
+
+---
+
+### Risk Assessment
+
+| Risk | Severity | Mitigation |
+|------|----------|------------|
+| **Breaking Change** | π‘ Medium | All getChain() usages updated in PR 2b (atomic) |
+| **SSR Hydration Mismatch** | π‘ Medium | Use `getDefaultNetwork()` for server, `useAutoNetwork()` for client |
+| **Hardcoded Chain IDs** | π’ Low | Grep for `11155420`, `10`, `8453`, `84532` and replace |
+| **Wagmi Hook Context** | π’ Low | `useAutoNetwork` only used in components with WagmiProvider |
+
+### Checklists by PR
+
+**PR 2a: Infrastructure (non-breaking)**
+- [ ] `package.json` - Add @fretchen/chain-utils dependency
+- [ ] `hooks/useAutoNetwork.ts` - **CREATE NEW**
+- [ ] `utils/getChain.ts` - Add re-exports (keep old exports)
+- [ ] `utils/nodeChainUtils.ts` - Add `getDefaultNetwork()`
+
+**PR 2b: Component Migration (breaking)**
+- [ ] `utils/getChain.ts` - Remove old exports (`getChain`, `genAiNFTContractConfig`)
+- [ ] `utils/nftLoader.ts` - Use chain-utils
+- [ ] `utils/nodeNftLoader.ts` - Use chain-utils
+- [ ] `components/MyNFTList.tsx` - Use `useAutoNetwork()`
+- [ ] `components/NFTCard.tsx` - Use `useAutoNetwork()` + `getGenAiNFTAddress()`
+- [ ] `components/NFTList.tsx` - Add network prop
+- [ ] `components/PublicNFTList.tsx` - Add network prop
+- [ ] `components/EntryNftImage.tsx` - Use `getDefaultNetwork()` for SSR
+- [ ] `components/NFTFloatImage.tsx` - Update to use network
+- [ ] `components/ImageGenerator.tsx` - Remove hardcoded chain ID
+- [ ] `components/AgentInfoPanel.tsx` - Use `useAutoNetwork()`
+- [ ] `test/ContractChainSelection.test.ts` - Update mocks
+- [ ] `test/MyNFTList.test.tsx` - Update mocks
+- [ ] `test/ImageGenerator.test.tsx` - Update if needed
+
---
## Phase 3: CollectorNFT-Komponenten migrieren
diff --git a/website/components/AgentInfoPanel.tsx b/website/components/AgentInfoPanel.tsx
index d44b7cc2d..2b0416429 100644
--- a/website/components/AgentInfoPanel.tsx
+++ b/website/components/AgentInfoPanel.tsx
@@ -18,7 +18,8 @@ import React, { useState } from "react";
import { css } from "../styled-system/css";
import { useAgentInfo } from "../hooks/useAgentInfo";
import { useLocale } from "../hooks/useLocale";
-import { genAiNFTContractConfig, llmV1ContractConfig } from "../utils/getChain";
+import { useAutoNetwork } from "../hooks/useAutoNetwork";
+import { getGenAiNFTAddress, getLLMv1Address, GENAI_NFT_NETWORKS, LLM_V1_NETWORKS } from "@fretchen/chain-utils";
interface AgentInfoPanelProps {
// Service context (for display purposes)
@@ -34,6 +35,10 @@ export function AgentInfoPanel({ service = "genimg", variant = "footer" }: Agent
// Localized texts
const poweredByText = useLocale({ label: "imagegen.poweredBy" });
+ // Get contract address based on service - hooks must be called before early returns
+ const { network: genimgNetwork } = useAutoNetwork(GENAI_NFT_NETWORKS);
+ const { network: llmNetwork } = useAutoNetwork(LLM_V1_NETWORKS);
+
const isSidebar = variant === "sidebar";
if (isLoading) {
@@ -68,7 +73,8 @@ export function AgentInfoPanel({ service = "genimg", variant = "footer" }: Agent
const serviceEndpoint = service === "genimg" ? agent.genimgEndpoint : agent.llmEndpoint;
const serviceHostname = serviceEndpoint ? new URL(serviceEndpoint).hostname : null;
- const contractAddress = service === "genimg" ? genAiNFTContractConfig.address : llmV1ContractConfig.address;
+
+ const contractAddress = service === "genimg" ? getGenAiNFTAddress(genimgNetwork) : getLLMv1Address(llmNetwork);
// Sidebar variant - vertical layout
if (isSidebar) {
diff --git a/website/components/EntryNftImage.tsx b/website/components/EntryNftImage.tsx
index cccbc4bb5..c2bd32622 100644
--- a/website/components/EntryNftImage.tsx
+++ b/website/components/EntryNftImage.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import { entryList } from "../layouts/styles";
-import { genAiNFTContractConfig } from "../utils/getChain";
+import { useAutoNetwork } from "../hooks/useAutoNetwork";
+import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils";
import { useConfiguredPublicClient } from "../hooks/useConfiguredPublicClient";
import { NFTMetadata } from "../types/components";
@@ -16,7 +17,9 @@ interface EntryNftImageProps {
export const EntryNftImage: React.FC = ({ tokenId, fallbackImageUrl, nftName }) => {
const [imageUrl, setImageUrl] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(true);
- const publicClient = useConfiguredPublicClient();
+ const { network } = useAutoNetwork(GENAI_NFT_NETWORKS);
+ const contractAddress = getGenAiNFTAddress(network);
+ const publicClient = useConfiguredPublicClient(network);
// Fetch metadata from tokenURI
const fetchNFTMetadata = async (tokenURI: string): Promise => {
@@ -53,8 +56,8 @@ export const EntryNftImage: React.FC = ({ tokenId, fallbackI
if (tokenId && publicClient) {
// Get token URI using public client (same as NFTFloatImage)
const tokenURIResult = await publicClient.readContract({
- address: genAiNFTContractConfig.address,
- abi: genAiNFTContractConfig.abi,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "tokenURI",
args: [BigInt(tokenId)],
});
diff --git a/website/components/ImageGenerator.tsx b/website/components/ImageGenerator.tsx
index 0fd40438c..5da70c247 100644
--- a/website/components/ImageGenerator.tsx
+++ b/website/components/ImageGenerator.tsx
@@ -1,7 +1,8 @@
-import React, { useState } from "react";
-import { useAccount, useConnect, useSwitchChain, useChainId } from "wagmi";
+import React, { useState, useEffect } from "react";
+import { useAccount, useConnect } from "wagmi";
import { css } from "../styled-system/css";
-import { getChain } from "../utils/getChain";
+import { useAutoNetwork } from "../hooks/useAutoNetwork";
+import { GENAI_NFT_NETWORKS, fromCAIP2, isTestnet, getViemChain } from "@fretchen/chain-utils";
import { ImageGeneratorProps } from "../types/components";
import * as styles from "../layouts/styles";
import InfoIcon from "./InfoIcon";
@@ -100,6 +101,12 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
const [generatedImageUrl, setGeneratedImageUrl] = useState();
const [tokenId, setTokenId] = useState();
+ // Hydration safety: prevent SSR/client mismatch by showing collapsed state until mounted
+ const [hasMounted, setHasMounted] = useState(false);
+ useEffect(() => {
+ setHasMounted(true);
+ }, []);
+
// Preview area state
const [currentPreviewImage, setCurrentPreviewImage] = useState();
@@ -110,12 +117,16 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
// Blockchain interaction
const { address, isConnected } = useAccount();
const { connectors, connect } = useConnect();
- const { switchChainAsync, isPending: isSwitchingChain } = useSwitchChain();
- const currentChainId = useChainId();
- // Determine target chain from centralized config (PUBLIC_ENV__CHAIN_NAME)
- const targetChain = getChain();
- const useTestnet = targetChain.id === 11155420; // optimismSepolia.id
+ // Effective connection state: only true after client has mounted
+ // This prevents hydration mismatch between SSR (always false) and client (may be true)
+ const isEffectivelyConnected = hasMounted && isConnected;
+
+ // Determine target chain from useAutoNetwork (no auto-switch, switch at interaction)
+ const { network, switchIfNeeded } = useAutoNetwork(GENAI_NFT_NETWORKS);
+ const targetChainId = fromCAIP2(network);
+ const targetChain = getViemChain(network);
+ const useTestnetFlag = isTestnet(network);
// Preview area state machine
type PreviewState = "empty" | "reference" | "generated";
@@ -149,7 +160,6 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
const enterPromptErrorText = useLocale({ label: "imagegen.enterPromptError" });
const unknownErrorText = useLocale({ label: "imagegen.unknownError" });
const chainSwitchFailedText = useLocale({ label: "imagegen.chainSwitchFailed" });
- const switchingNetworkText = useLocale({ label: "imagegen.switchingNetwork" });
// x402 specific messages
const awaitingSignatureText = useLocale({ label: "imagegen.awaitingSignature" });
@@ -188,7 +198,6 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
const checkGalleryText = useLocale({ label: "imagegen.checkGallery" });
const getButtonState = (): string => {
- if (isSwitchingChain) return "switching";
if (isLoading || x402Status === "awaiting-signature" || x402Status === "processing") return "loading";
if (!prompt.trim()) return "needsPrompt";
return "ready";
@@ -196,8 +205,6 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
const getButtonText = (state: string) => {
switch (state) {
- case "switching":
- return switchingNetworkText;
case "loading":
if (x402Status === "awaiting-signature") return awaitingSignatureText;
if (x402Status === "processing") return processingPaymentText;
@@ -217,8 +224,8 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
// Button Components
const CreateArtworkButton = () => {
- const isDisabled = buttonState === "needsPrompt";
- const isLoadingState = buttonState === "loading" || buttonState === "switching";
+ const isLoadingState = buttonState === "loading";
+ const isDisabled = buttonState === "needsPrompt" || isLoadingState;
const handleClick = () => {
// Track create artwork attempt with context
@@ -271,25 +278,22 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
return;
}
- // === Automatic Chain Switch ===
- // Ensure user is on the correct chain before making payment
- if (currentChainId !== targetChain.id) {
- console.log(`[x402] Chain mismatch: current=${currentChainId}, target=${targetChain.id} (${targetChain.name})`);
- try {
- await switchChainAsync({ chainId: targetChain.id });
- console.log(`[x402] Successfully switched to ${targetChain.name}`);
- } catch (switchError) {
- console.error("[x402] Chain switch failed:", switchError);
- const errorMsg = `${chainSwitchFailedText}: ${targetChain.name}`;
- setError(errorMsg);
- onError?.(errorMsg);
- return;
- }
- }
-
+ // Enter loading state immediately to prevent double-clicks
setIsLoading(true);
setError(null);
+ // === Chain Switch at Interaction ===
+ // Ensure user is on the correct chain before making payment
+ const switched = await switchIfNeeded();
+ if (!switched) {
+ console.error("[x402] Chain switch rejected by user");
+ const errorMsg = `${chainSwitchFailedText}: ${targetChain.name}`;
+ setError(errorMsg);
+ onError?.(errorMsg);
+ setIsLoading(false);
+ return;
+ }
+
try {
// Determine mode
const isEditMode = referenceImageBase64 !== null;
@@ -302,16 +306,14 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
size,
mode,
referenceImage: isEditMode ? referenceImageBase64 : undefined,
- // Use testnet: derived from PUBLIC_ENV__CHAIN_NAME
- sepoliaTest: useTestnet,
+ // Use testnet: derived from useAutoNetwork
+ sepoliaTest: useTestnetFlag,
// Pass expected chain ID for validation in hook
- expectedChainId: targetChain.id,
+ expectedChainId: targetChainId,
// Whether to list in public gallery
isListed,
});
- console.log("[x402] Image generation completed:", result);
-
// Update state with results
const newTokenId = BigInt(result.tokenId);
const imageUrl = result.image_url;
@@ -412,12 +414,7 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
setReferenceImageMimeType(compressedResult.mimeType);
setPreviewState("reference");
- // Zeige Erfolg-Feedback
- const originalSizeKB = Math.round(file.size / 1024);
- const compressedSizeKB = Math.round((compressedResult.base64.length * 0.75) / 1024);
- console.log(
- `Image compressed: ${originalSizeKB}KB β ${compressedSizeKB}KB (${file.type} β ${compressedResult.mimeType})`,
- );
+ // Image compression succeeded - no logging needed in production
} catch (err) {
console.error("Image compression failed:", err);
const errorMsg = err instanceof Error ? err.message : failedToProcessImageText;
@@ -523,8 +520,8 @@ export function ImageGenerator({ onSuccess, onError }: ImageGeneratorProps) {
AI Image Generation is currently under maintenance. Please check back later.
- ) : !isConnected ? (
- // Collapsed State - Clean & Simple
+ ) : !isEffectivelyConnected ? (
+ // Collapsed State - Clean & Simple (also shown during SSR before hydration)
([]);
@@ -29,10 +35,11 @@ export function MyNFTList({ newlyCreatedNFT, onNewNFTDisplayed }: MyNFTListProps
// Get user's NFT balance
const { data: userBalance, isLoading: isLoadingBalance } = useReadContract({
- ...genAiNFTContractConfig,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
- chainId: chain.id,
+ chainId,
query: {
enabled: !!address && isConnected,
},
@@ -55,10 +62,11 @@ export function MyNFTList({ newlyCreatedNFT, onNewNFTDisplayed }: MyNFTListProps
try {
// Get fresh balance directly from contract to avoid stale state
const freshBalance = await readContract(config, {
- ...genAiNFTContractConfig,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "balanceOf",
args: [address],
- chainId: chain.id,
+ chainId,
});
if (!freshBalance || freshBalance === 0n) {
@@ -74,10 +82,11 @@ export function MyNFTList({ newlyCreatedNFT, onNewNFTDisplayed }: MyNFTListProps
try {
// Get token ID at index using wagmi's readContract
const tokenIdResult = await readContract(config, {
- ...genAiNFTContractConfig,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "tokenOfOwnerByIndex",
args: [address, BigInt(i)],
- chainId: chain.id,
+ chainId,
});
return tokenIdResult as bigint;
@@ -111,7 +120,7 @@ export function MyNFTList({ newlyCreatedNFT, onNewNFTDisplayed }: MyNFTListProps
} finally {
setIsLoadingTokenIds(false);
}
- }, [isConnected, address]); // Removed genAiNFTContractConfig as it's now stable
+ }, [isConnected, address, contractAddress, chainId]);
// Handle newly created NFT - just add to token list
const handleNewlyCreatedNFT = useCallback(
@@ -138,10 +147,9 @@ export function MyNFTList({ newlyCreatedNFT, onNewNFTDisplayed }: MyNFTListProps
);
// Handle listing status changes
- const handleListedStatusChanged = useCallback((tokenId: bigint, isListed: boolean) => {
- // This function can be used to update local state if needed
+ const handleListedStatusChanged = useCallback((_tokenId: bigint, _isListed: boolean) => {
+ // This callback can be used to update local state if needed
// For now, the NFTCard handles the blockchain state itself
- console.log(`NFT ${tokenId} listing status changed to: ${isListed}`);
}, []);
// Load data when component mounts or dependencies change
diff --git a/website/components/NFTCard.tsx b/website/components/NFTCard.tsx
index 6af7628a6..1cdcc5dcb 100644
--- a/website/components/NFTCard.tsx
+++ b/website/components/NFTCard.tsx
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from "react";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
-import { readContract } from "wagmi/actions";
-import { config } from "../wagmi.config";
-import { genAiNFTContractConfig, getChain } from "../utils/getChain";
+import { useAutoNetwork } from "../hooks/useAutoNetwork";
+import { useNFTListedStatus } from "../hooks/useNFTListedStatus";
+import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS, isMainnet } from "@fretchen/chain-utils";
import { useConfiguredPublicClient } from "../hooks/useConfiguredPublicClient";
import { NFTCardProps, NFT, NFTMetadata } from "../types/components";
import { useToast } from "./Toast";
@@ -43,8 +43,24 @@ export function NFTCard({
const deleteLabel = useLocale({ label: "imagegen.delete" });
+ // Get network and contract address from chain-utils
+ const { network, switchIfNeeded } = useAutoNetwork(GENAI_NFT_NETWORKS);
+ const contractAddress = getGenAiNFTAddress(network);
+
+ // Use the extracted hook for listing status
+ // Only enabled when:
+ // 1. Not public view (owners can toggle listing)
+ // 2. Token data loaded successfully (no error, not loading)
+ // This prevents contract calls for non-existent/burned tokens
+ const tokenDataLoaded = !nft.isLoading && !nft.error;
+ const { isListed, setOptimisticListed } = useNFTListedStatus({
+ tokenId,
+ network,
+ enabled: !isPublicView && tokenDataLoaded,
+ });
+
// Use the custom hook for a stable public client reference
- const publicClient = useConfiguredPublicClient();
+ const publicClient = useConfiguredPublicClient(network);
// Fetch metadata from tokenURI
const fetchNFTMetadata = async (tokenURI: string): Promise
=> {
@@ -76,8 +92,8 @@ export function NFTCard({
// Get token URI using public client
const tokenURIResult = await publicClient.readContract({
- address: genAiNFTContractConfig.address,
- abi: genAiNFTContractConfig.abi,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "tokenURI",
args: [tokenId],
});
@@ -98,8 +114,8 @@ export function NFTCard({
let nftOwner = "";
if (isPublicView) {
const ownerResult = await publicClient.readContract({
- address: genAiNFTContractConfig.address,
- abi: genAiNFTContractConfig.abi,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "ownerOf",
args: [tokenId],
});
@@ -107,22 +123,7 @@ export function NFTCard({
setOwner(nftOwner);
}
- // Get listing status if not public view
- let isListed: boolean | undefined;
- if (!isPublicView) {
- try {
- const chain = getChain();
- const isListedResult = await readContract(config, {
- ...genAiNFTContractConfig,
- functionName: "isTokenListed",
- args: [tokenId],
- chainId: chain.id,
- });
- isListed = isListedResult as boolean;
- } catch (error) {
- console.warn("Could not load listing status:", error);
- }
- }
+ // Note: isListed is now handled by useNFTListedStatus hook
// Fetch metadata only if tokenURI is available
let metadata = preloadedMetadata; // Keep preloaded metadata as fallback
@@ -142,7 +143,6 @@ export function NFTCard({
metadata,
imageUrl: finalImageUrl,
isLoading: false,
- isListed,
});
} catch (error) {
console.error(`Error loading NFT ${tokenId}:`, error);
@@ -224,8 +224,9 @@ export function NFTCard({
* Uses the Optimism network OpenSea URL format
*/
const handleShare = async () => {
- const contractAddress = genAiNFTContractConfig.address;
- const openSeaUrl = `https://opensea.io/item/optimism/${contractAddress}/${nft.tokenId}`;
+ // Determine OpenSea network based on mainnet/testnet
+ const openSeaNetwork = isMainnet(network) ? "optimism" : "optimism-sepolia";
+ const openSeaUrl = `https://opensea.io/item/${openSeaNetwork}/${contractAddress}/${nft.tokenId}`;
try {
await navigator.clipboard.writeText(openSeaUrl);
@@ -259,8 +260,16 @@ export function NFTCard({
setShowDeleteConfirmation(false);
try {
+ // Switch chain if needed before transaction
+ const switched = await switchIfNeeded();
+ if (!switched) {
+ showToast("Please switch to the correct network.", "error");
+ return;
+ }
+
await writeContract({
- ...genAiNFTContractConfig,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "burn",
args: [nft.tokenId],
});
@@ -273,22 +282,32 @@ export function NFTCard({
const handleToggleListing = async () => {
if (!onListedStatusChanged) return;
- const newListedStatus = !nft.isListed;
+ const newListedStatus = !isListed;
const statusText = newListedStatus ? "public" : "private";
try {
- // Update UI optimistically
+ // Switch chain if needed before transaction
+ const switched = await switchIfNeeded();
+ if (!switched) {
+ showToast("Please switch to the correct network.", "error");
+ return;
+ }
+
+ // Update UI optimistically (both local state and parent)
+ setOptimisticListed(newListedStatus);
onListedStatusChanged(nft.tokenId, newListedStatus);
// Call contract
await writeListingContract({
- ...genAiNFTContractConfig,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "setTokenListed",
args: [nft.tokenId, newListedStatus],
});
} catch (error) {
console.error("Failed to update listing status:", error);
// Revert optimistic update on error
+ setOptimisticListed(!newListedStatus);
onListedStatusChanged(nft.tokenId, !newListedStatus);
showToast(`Failed to set artwork as ${statusText}. Please try again.`, "error");
}
@@ -338,7 +357,7 @@ export function NFTCard({
#{nft.tokenId.toString()}
{/* Listed Badge (nur wenn listed) */}
- {!isPublicView && nft.isListed && β {listedLabel}
}
+ {!isPublicView && isListed && β {listedLabel}
}
{/* Owner Badge (nur in Public View) */}
{isPublicView && owner && (
@@ -392,7 +411,7 @@ export function NFTCard({
{/* Listed Toggle (nur private view) */}
- {!isPublicView && onListedStatusChanged && nft.isListed !== undefined && (
+ {!isPublicView && onListedStatusChanged && isListed !== undefined && (
)}
diff --git a/website/components/NFTFloatImage.tsx b/website/components/NFTFloatImage.tsx
index 58c1da13c..7a7e5daf7 100644
--- a/website/components/NFTFloatImage.tsx
+++ b/website/components/NFTFloatImage.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
-import { genAiNFTContractConfig } from "../utils/getChain";
+import { useAutoNetwork } from "../hooks/useAutoNetwork";
+import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils";
import { useConfiguredPublicClient } from "../hooks/useConfiguredPublicClient";
import { extractPromptFromDescription } from "../utils/nftMetadataUtils";
import * as styles from "../layouts/styles";
@@ -17,8 +18,9 @@ export function NFTFloatImage({ tokenId }: NFTFloatImageProps) {
const [nftDescription, setNftDescription] = useState(null);
const [error, setError] = useState(null);
- // Memoize the contract config to prevent infinite re-renders
- // The stable genAiNFTContractConfig constant is used directly
+ // Get network and contract address from chain-utils
+ const { network } = useAutoNetwork(GENAI_NFT_NETWORKS);
+ const contractAddress = getGenAiNFTAddress(network);
// Extract prompt from description for display (reusing utility function)
const getPromptPreview = (description: string | null): string => {
@@ -36,7 +38,7 @@ export function NFTFloatImage({ tokenId }: NFTFloatImageProps) {
};
// Use the custom hook for a stable public client reference
- const publicClient = useConfiguredPublicClient();
+ const publicClient = useConfiguredPublicClient(network);
// Fetch metadata from tokenURI
const fetchNFTMetadata = async (tokenURI: string): Promise => {
@@ -67,8 +69,8 @@ export function NFTFloatImage({ tokenId }: NFTFloatImageProps) {
// Get token URI using public client
const tokenURIResult = await publicClient.readContract({
- address: genAiNFTContractConfig.address,
- abi: genAiNFTContractConfig.abi,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "tokenURI",
args: [BigInt(tokenId)],
});
diff --git a/website/components/NFTList.tsx b/website/components/NFTList.tsx
index 3802a0287..3fd1de9c3 100644
--- a/website/components/NFTList.tsx
+++ b/website/components/NFTList.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { useAccount, useReadContract } from "wagmi";
-import { getChain, genAiNFTContractConfig } from "../utils/getChain";
+import { useAutoNetwork } from "../hooks/useAutoNetwork";
+import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS, fromCAIP2 } from "@fretchen/chain-utils";
import { NFTListProps } from "../types/components";
import * as styles from "../layouts/styles";
import { Tab } from "./Tab";
@@ -23,12 +24,18 @@ export function NFTList({
const activeTab = controlledActiveTab ?? localActiveTab;
const setActiveTab = onTabChange ?? setLocalActiveTab;
+ // Get network from useAutoNetwork
+ const { network } = useAutoNetwork(GENAI_NFT_NETWORKS);
+ const chainId = fromCAIP2(network);
+ const contractAddress = getGenAiNFTAddress(network);
+
// Get user's NFT balance for display in tab
const userBalanceQuery = useReadContract({
- ...genAiNFTContractConfig,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
- chainId: getChain().id,
+ chainId,
query: {
enabled: !!address && isConnected,
},
diff --git a/website/components/PublicNFTList.tsx b/website/components/PublicNFTList.tsx
index 0317940d8..b096a40a4 100644
--- a/website/components/PublicNFTList.tsx
+++ b/website/components/PublicNFTList.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
-import { genAiNFTContractConfig } from "../utils/getChain";
+import { useAutoNetwork } from "../hooks/useAutoNetwork";
+import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils";
import { useConfiguredPublicClient } from "../hooks/useConfiguredPublicClient";
import { ModalImageData } from "../types/components";
import * as styles from "../layouts/styles";
@@ -7,7 +8,9 @@ import { NFTCard } from "./NFTCard";
import { ImageModal } from "./ImageModal";
export function PublicNFTList() {
- // Use the stable genAiNFTContractConfig constant
+ // Get network and contract address from chain-utils
+ const { network } = useAutoNetwork(GENAI_NFT_NETWORKS);
+ const contractAddress = getGenAiNFTAddress(network);
// Public NFTs state - now just store token IDs
const [publicTokenIds, setPublicTokenIds] = useState([]);
@@ -15,7 +18,7 @@ export function PublicNFTList() {
const [selectedImage, setSelectedImage] = useState(null);
// Use the custom hook for a stable public client reference
- const publicClient = useConfiguredPublicClient();
+ const publicClient = useConfiguredPublicClient(network);
// Load all public token IDs using getAllPublicTokens
const loadPublicTokenIds = useCallback(async () => {
@@ -24,8 +27,8 @@ export function PublicNFTList() {
try {
// Get all public token IDs using the public client
const tokenIds = (await publicClient.readContract({
- address: genAiNFTContractConfig.address,
- abi: genAiNFTContractConfig.abi,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "getAllPublicTokens",
})) as bigint[];
diff --git a/website/hooks/useAutoNetwork.ts b/website/hooks/useAutoNetwork.ts
new file mode 100644
index 000000000..facaf8a09
--- /dev/null
+++ b/website/hooks/useAutoNetwork.ts
@@ -0,0 +1,82 @@
+/**
+ * Hook for network detection with deferred switching.
+ *
+ * Detects the user's connected wallet chain and returns the target CAIP-2 network.
+ * Does NOT auto-switch - instead provides a `switchIfNeeded()` function
+ * to call at interaction time (submit button).
+ *
+ * This pattern is more user-friendly:
+ * - No surprising wallet popups on connect
+ * - Switch happens in context of an action (expected by users)
+ * - Beginners don't need to understand chains
+ */
+
+import { useAccount, useChainId, useSwitchChain } from "wagmi";
+import { useCallback } from "react";
+import { toCAIP2, fromCAIP2 } from "@fretchen/chain-utils";
+
+interface UseAutoNetworkResult {
+ /** Target CAIP-2 network for transactions */
+ network: string;
+ /** Whether wallet is already on the target network */
+ isOnCorrectNetwork: boolean;
+ /** Call before submitting a transaction - switches chain if needed */
+ switchIfNeeded: () => Promise;
+}
+
+/**
+ * Returns the target CAIP-2 network and a function to switch when needed.
+ *
+ * @param supportedNetworks - Array of CAIP-2 network strings (e.g., ["eip155:10", "eip155:11155420"])
+ * @returns Object with network, isOnCorrectNetwork, and switchIfNeeded()
+ *
+ * @example
+ * ```tsx
+ * import { GENAI_NFT_NETWORKS } from "@fretchen/chain-utils";
+ *
+ * function MyComponent() {
+ * const { network, isOnCorrectNetwork, switchIfNeeded } = useAutoNetwork(GENAI_NFT_NETWORKS);
+ *
+ * const handleSubmit = async () => {
+ * const switched = await switchIfNeeded();
+ * if (!switched) return; // User rejected switch
+ * // Proceed with transaction on `network`
+ * };
+ * }
+ * ```
+ */
+export function useAutoNetwork(supportedNetworks: readonly string[]): UseAutoNetworkResult {
+ const chainId = useChainId();
+ const { isConnected } = useAccount();
+ const { switchChainAsync } = useSwitchChain();
+
+ const defaultNetwork = supportedNetworks[0];
+ const currentNetwork = toCAIP2(chainId);
+ const isSupported = supportedNetworks.includes(currentNetwork);
+
+ // Return the current network if supported, otherwise the default
+ const network = isSupported ? currentNetwork : defaultNetwork;
+
+ const switchIfNeeded = useCallback(async (): Promise => {
+ // Already on correct network
+ if (isSupported) return true;
+
+ // Not connected - nothing to switch
+ if (!isConnected) return true;
+
+ // Try to switch
+ try {
+ await switchChainAsync({ chainId: fromCAIP2(defaultNetwork) });
+ return true;
+ } catch {
+ // User rejected or error
+ return false;
+ }
+ }, [isSupported, isConnected, switchChainAsync, defaultNetwork]);
+
+ return {
+ network,
+ isOnCorrectNetwork: isSupported,
+ switchIfNeeded,
+ };
+}
diff --git a/website/hooks/useConfiguredPublicClient.ts b/website/hooks/useConfiguredPublicClient.ts
index e2b802b56..6f343bb44 100644
--- a/website/hooks/useConfiguredPublicClient.ts
+++ b/website/hooks/useConfiguredPublicClient.ts
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { getPublicClient } from "@wagmi/core";
import { config } from "../wagmi.config";
-import { getChain } from "../utils/getChain";
+import { fromCAIP2 } from "@fretchen/chain-utils";
/**
* Custom hook that provides a stable reference to the configured public client.
@@ -10,12 +10,13 @@ import { getChain } from "../utils/getChain";
* The client is created once per component mount and remains stable throughout
* the component's lifecycle.
*
+ * @param network - CAIP-2 network string (e.g., "eip155:10"). Required.
* @returns A stable public client instance configured with the correct chain
*/
-export function useConfiguredPublicClient() {
+export function useConfiguredPublicClient(network: string) {
return useMemo(() => {
- const chain = getChain();
+ const chainId = fromCAIP2(network);
// Must pass chainId explicitly to get the correct chain's public client
- return getPublicClient(config, { chainId: chain.id });
- }, []);
+ return getPublicClient(config, { chainId });
+ }, [network]);
}
diff --git a/website/hooks/useNFTListedStatus.ts b/website/hooks/useNFTListedStatus.ts
new file mode 100644
index 000000000..0521ead91
--- /dev/null
+++ b/website/hooks/useNFTListedStatus.ts
@@ -0,0 +1,122 @@
+/**
+ * Hook to fetch and manage the listing status of an NFT.
+ *
+ * This hook encapsulates the logic for checking if an NFT is publicly listed
+ * via the smart contract's isTokenListed function.
+ *
+ * @example
+ * ```tsx
+ * const { isListed, isLoading, error, refetch } = useNFTListedStatus({
+ * tokenId: BigInt(42),
+ * network: "eip155:10",
+ * enabled: !isPublicView, // Only fetch in private view
+ * });
+ * ```
+ */
+
+import { useState, useEffect, useCallback } from "react";
+import { readContract } from "wagmi/actions";
+import { config } from "../wagmi.config";
+import { getGenAiNFTAddress, GenImNFTv4ABI, fromCAIP2 } from "@fretchen/chain-utils";
+
+export interface UseNFTListedStatusOptions {
+ /** The token ID to check */
+ tokenId: bigint;
+ /** The CAIP-2 network string (e.g., "eip155:10") */
+ network: string;
+ /** Whether to enable the query (default: true) */
+ enabled?: boolean;
+}
+
+export interface UseNFTListedStatusResult {
+ /** Whether the NFT is listed (undefined if not yet loaded) */
+ isListed: boolean | undefined;
+ /** Whether the query is currently loading */
+ isLoading: boolean;
+ /** Error message if the query failed */
+ error: string | undefined;
+ /** Function to manually refetch the listing status */
+ refetch: () => Promise;
+ /** Optimistically update the listing status (for UI feedback) */
+ setOptimisticListed: (value: boolean) => void;
+}
+
+/**
+ * Fetches and manages the listing status of an NFT from the smart contract.
+ *
+ * The isListed status determines whether an NFT appears in the public gallery.
+ * This hook only fetches in private view (when enabled=true).
+ */
+export function useNFTListedStatus({
+ tokenId,
+ network,
+ enabled = true,
+}: UseNFTListedStatusOptions): UseNFTListedStatusResult {
+ const [isListed, setIsListed] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(undefined);
+
+ const chainId = fromCAIP2(network);
+ const contractAddress = getGenAiNFTAddress(network);
+
+ const fetchListedStatus = useCallback(async () => {
+ if (!enabled) {
+ // Don't reset isListed when disabled - keep the last known value
+ // This prevents flickering when enabled toggles temporarily
+ return;
+ }
+
+ setIsLoading(true);
+ setError(undefined);
+
+ try {
+ const result = await readContract(config, {
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
+ functionName: "isTokenListed",
+ args: [tokenId],
+ chainId,
+ });
+
+ setIsListed(result as boolean);
+ } catch (err) {
+ // Distinguish between contract reverts (legacy tokens) and real errors
+ const isContractRevert =
+ err instanceof Error &&
+ (err.message.includes("reverted") ||
+ err.name.includes("ContractFunctionRevertedError") ||
+ err.name.includes("ContractFunctionExecutionError"));
+
+ if (isContractRevert) {
+ // Legacy tokens minted before isListed feature - not an error, just not supported
+ // Keep isListed as undefined - UI will hide the toggle for these tokens
+ setError("isListed not available for this token");
+ } else {
+ // Real errors (network issues, etc.) - log and set error
+ console.error("Failed to load listing status:", err);
+ setError(err instanceof Error ? err.message : "Failed to load listing status");
+ }
+ // In both cases, isListed remains undefined
+ } finally {
+ setIsLoading(false);
+ }
+ }, [tokenId, network, enabled, contractAddress, chainId]);
+
+ // Fetch on mount and when dependencies change
+ useEffect(() => {
+ fetchListedStatus();
+ }, [fetchListedStatus]);
+
+ // Optimistic update for immediate UI feedback
+ const setOptimisticListed = useCallback((value: boolean) => {
+ setIsListed(value);
+ }, []);
+
+ return {
+ isListed,
+ isLoading,
+ error,
+ refetch: fetchListedStatus,
+ setOptimisticListed,
+ };
+}
diff --git a/website/package-lock.json b/website/package-lock.json
index 470d2c834..82b7f4a5c 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -5,6 +5,7 @@
"packages": {
"": {
"dependencies": {
+ "@fretchen/chain-utils": "file:../shared/chain-utils",
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@mdx-js/rollup": "^3.1.1",
@@ -60,6 +61,25 @@
"vitest": "^4.0.1"
}
},
+ "../shared/chain-utils": {
+ "name": "@fretchen/chain-utils",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@eslint/js": "^9.39.2",
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
+ "@typescript-eslint/parser": "^8.0.0",
+ "@vitest/coverage-v8": "^4.0.18",
+ "eslint": "^9.39.2",
+ "prettier": "^3.8.1",
+ "typescript": "^5.0.0",
+ "typescript-eslint": "^8.53.1",
+ "viem": "^2.0.0",
+ "vitest": "^4.0.18"
+ },
+ "peerDependencies": {
+ "viem": "^2.0.0"
+ }
+ },
"node_modules/@adobe/css-tools": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz",
@@ -1698,6 +1718,10 @@
"@scure/bip39": "1.3.0"
}
},
+ "node_modules/@fretchen/chain-utils": {
+ "resolved": "../shared/chain-utils",
+ "link": true
+ },
"node_modules/@gemini-wallet/core": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@gemini-wallet/core/-/core-0.2.0.tgz",
diff --git a/website/package.json b/website/package.json
index c8c6a29f9..bc3fb16ae 100644
--- a/website/package.json
+++ b/website/package.json
@@ -15,6 +15,7 @@
"send-webmentions": "tsx ./utils/sendWebmentions.ts"
},
"dependencies": {
+ "@fretchen/chain-utils": "file:../shared/chain-utils",
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@mdx-js/rollup": "^3.1.1",
diff --git a/website/test/AgentInfoPanel.test.tsx b/website/test/AgentInfoPanel.test.tsx
new file mode 100644
index 000000000..3563d2a52
--- /dev/null
+++ b/website/test/AgentInfoPanel.test.tsx
@@ -0,0 +1,200 @@
+/**
+ * AgentInfoPanel Component Tests
+ *
+ * Ensures the component renders correctly under all states (loading, error, success)
+ * without violating React hooks rules.
+ *
+ * Bug Prevention: This test catches the "Rendered more hooks than during previous render"
+ * error that occurs when hooks are called after early returns.
+ */
+
+import React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { AgentInfoPanel } from "../components/AgentInfoPanel";
+
+// Mock useAgentInfo with different states
+const mockUseAgentInfo = vi.fn();
+vi.mock("../hooks/useAgentInfo", () => ({
+ useAgentInfo: () => mockUseAgentInfo(),
+}));
+
+// Mock useLocale
+vi.mock("../hooks/useLocale", () => ({
+ useLocale: vi.fn(() => "Powered by"),
+}));
+
+// Mock useAutoNetwork - must be called consistently regardless of early returns
+vi.mock("../hooks/useAutoNetwork", () => ({
+ useAutoNetwork: vi.fn(() => ({
+ network: "eip155:10",
+ isOnCorrectNetwork: true,
+ switchIfNeeded: vi.fn(() => Promise.resolve(true)),
+ })),
+}));
+
+// Mock chain-utils
+vi.mock("@fretchen/chain-utils", () => ({
+ getGenAiNFTAddress: vi.fn(() => "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb"),
+ getLLMv1Address: vi.fn(() => "0x1234567890123456789012345678901234567890"),
+ GENAI_NFT_NETWORKS: ["eip155:10", "eip155:11155420"],
+ LLM_V1_NETWORKS: ["eip155:10", "eip155:11155420"],
+}));
+
+// Mock styles
+vi.mock("../styled-system/css", () => ({
+ css: () => "mock-css-class",
+}));
+
+describe("AgentInfoPanel Component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("Hooks Consistency (Bug Prevention)", () => {
+ /**
+ * This test specifically catches the bug where hooks were called after early returns.
+ * React requires hooks to be called in the same order on every render.
+ * By testing all three states sequentially, we ensure hooks are always called.
+ */
+ it("should render without hooks error in loading state", () => {
+ mockUseAgentInfo.mockReturnValue({
+ agent: {},
+ isLoading: true,
+ error: null,
+ });
+
+ // Should not throw "Rendered more hooks than during previous render"
+ expect(() => render()).not.toThrow();
+ expect(screen.getByText(/Powered by.*Optimism/)).toBeInTheDocument();
+ });
+
+ it("should render without hooks error in error state", () => {
+ mockUseAgentInfo.mockReturnValue({
+ agent: { wallet: null },
+ isLoading: false,
+ error: new Error("Failed to fetch"),
+ });
+
+ expect(() => render()).not.toThrow();
+ expect(screen.getByText("Optimism")).toBeInTheDocument();
+ });
+
+ it("should render without hooks error in success state", () => {
+ mockUseAgentInfo.mockReturnValue({
+ agent: {
+ wallet: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C",
+ walletShort: "0xAAEB...239C",
+ name: "Test Agent",
+ genimgEndpoint: "https://genimg.example.com/api",
+ llmEndpoint: "https://llm.example.com/api",
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ expect(() => render()).not.toThrow();
+ });
+
+ it("should render all states in sequence without hooks error", () => {
+ // This test simulates what happens during re-renders with changing state
+ // If hooks are called after early returns, this will fail
+
+ // First: loading
+ mockUseAgentInfo.mockReturnValue({
+ agent: {},
+ isLoading: true,
+ error: null,
+ });
+ const { rerender } = render();
+
+ // Then: success
+ mockUseAgentInfo.mockReturnValue({
+ agent: {
+ wallet: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C",
+ walletShort: "0xAAEB...239C",
+ genimgEndpoint: "https://genimg.example.com/api",
+ },
+ isLoading: false,
+ error: null,
+ });
+ expect(() => rerender()).not.toThrow();
+
+ // Then: error
+ mockUseAgentInfo.mockReturnValue({
+ agent: { wallet: null },
+ isLoading: false,
+ error: new Error("Network error"),
+ });
+ expect(() => rerender()).not.toThrow();
+
+ // Back to loading
+ mockUseAgentInfo.mockReturnValue({
+ agent: {},
+ isLoading: true,
+ error: null,
+ });
+ expect(() => rerender()).not.toThrow();
+ });
+ });
+
+ describe("Variants", () => {
+ it("should render footer variant", () => {
+ mockUseAgentInfo.mockReturnValue({
+ agent: {
+ wallet: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C",
+ walletShort: "0xAAEB...239C",
+ genimgEndpoint: "https://genimg.example.com/api",
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ expect(() => render()).not.toThrow();
+ });
+
+ it("should render sidebar variant", () => {
+ mockUseAgentInfo.mockReturnValue({
+ agent: {
+ wallet: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C",
+ walletShort: "0xAAEB...239C",
+ genimgEndpoint: "https://genimg.example.com/api",
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ expect(() => render()).not.toThrow();
+ });
+ });
+
+ describe("Service Types", () => {
+ it("should render genimg service", () => {
+ mockUseAgentInfo.mockReturnValue({
+ agent: {
+ wallet: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C",
+ walletShort: "0xAAEB...239C",
+ genimgEndpoint: "https://genimg.example.com/api",
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ expect(() => render()).not.toThrow();
+ });
+
+ it("should render llm service", () => {
+ mockUseAgentInfo.mockReturnValue({
+ agent: {
+ wallet: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C",
+ walletShort: "0xAAEB...239C",
+ llmEndpoint: "https://llm.example.com/api",
+ },
+ isLoading: false,
+ error: null,
+ });
+
+ expect(() => render()).not.toThrow();
+ });
+ });
+});
diff --git a/website/test/ContractChainSelection.test.ts b/website/test/ContractChainSelection.test.ts
index b8b5b4766..b3dff32f2 100644
--- a/website/test/ContractChainSelection.test.ts
+++ b/website/test/ContractChainSelection.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
-import { genAiNFTContractConfig, getChain } from "../utils/getChain";
+import { getGenAiNFTAddress, GenImNFTv4ABI, getViemChain } from "@fretchen/chain-utils";
import { getPublicClient } from "@wagmi/core";
import { config } from "../wagmi.config";
@@ -17,21 +17,31 @@ vi.mock("@wagmi/core", () => ({
}),
}));
+vi.mock("@fretchen/chain-utils", () => ({
+ getGenAiNFTAddress: vi.fn(() => "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb"),
+ GenImNFTv4ABI: [],
+ fromCAIP2: vi.fn((network: string) => parseInt(network.split(":")[1])),
+ getViemChain: vi.fn(() => ({ id: 10, name: "Optimism" })),
+}));
+
describe("Contract Chain Selection", () => {
it("should use configured chain even when wallet is on different chain", async () => {
- // Create client directly like the hook does now
- const chain = getChain();
+ // Use chain-utils pattern
+ const network = "eip155:10"; // Optimism mainnet
+ const chain = getViemChain(network);
const client = getPublicClient({ ...config, chains: [chain] });
expect(client).toBeDefined();
- // Try to read from contract
+ // Try to read from contract using chain-utils
+ const contractAddress = getGenAiNFTAddress(network);
const result = await client.readContract({
- address: genAiNFTContractConfig.address,
- abi: genAiNFTContractConfig.abi,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "getAllPublicTokens",
});
// We mainly care that the call succeeded - if it did, we used the right chain
expect(result).toBeDefined();
+ expect(contractAddress).toBe("0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb");
});
});
diff --git a/website/test/ImageGenerator.fileUpload.test.tsx b/website/test/ImageGenerator.fileUpload.test.tsx
index 4f72537b4..ea5b02a77 100644
--- a/website/test/ImageGenerator.fileUpload.test.tsx
+++ b/website/test/ImageGenerator.fileUpload.test.tsx
@@ -52,10 +52,10 @@ describe("ImageGenerator Reference Image Integration", () => {
it("sollte ImageGenerator Component rendern kΓΆnnen", () => {
render();
- // PrΓΌfe dass wichtige Elemente vorhanden sind
+ // LocaleText zeigt echte ΓΌbersetzte Texte, useLocale gibt Label-Keys zurΓΌck
expect(screen.getByText("Create Collectible AI Art β’ 10Β’")).toBeInTheDocument();
expect(screen.getByTestId("reference-image-input")).toBeInTheDocument();
- expect(screen.getByPlaceholderText("Describe your image in detail...")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("imagegen.promptPlaceholder")).toBeInTheDocument();
});
it("sollte File Input korrekt konfiguriert haben", () => {
@@ -72,9 +72,8 @@ describe("ImageGenerator Reference Image Integration", () => {
const dropZone = screen.getByTestId("drop-zone");
expect(dropZone).toBeInTheDocument();
- // Text is now split across elements, so check for parts
- expect(screen.getByText(/Drag & drop an image here/)).toBeInTheDocument();
- expect(screen.getByText(/click to browse/)).toBeInTheDocument();
+ // useLocale Mock gibt Label-Keys zurΓΌck
+ expect(screen.getByText("imagegen.uploadReferenceImage")).toBeInTheDocument();
});
it("sollte verschiedene Image Sizes unterstΓΌtzen", () => {
@@ -83,8 +82,9 @@ describe("ImageGenerator Reference Image Integration", () => {
const sizeSelect = screen.getByLabelText("Select image format for your artwork");
expect(sizeSelect).toBeInTheDocument();
- expect(screen.getByText("βΌ Square")).toBeInTheDocument();
- expect(screen.getByText("β¬ Wide")).toBeInTheDocument();
+ // useLocale Mock gibt Label-Keys zurΓΌck
+ expect(screen.getByText("imagegen.square")).toBeInTheDocument();
+ expect(screen.getByText("imagegen.wide")).toBeInTheDocument();
});
});
@@ -134,14 +134,15 @@ describe("ImageGenerator Reference Image Integration", () => {
it("sollte initial Create Artwork Button zeigen", () => {
render();
- const button = screen.getByRole("button", { name: /Enter a prompt to create/ });
+ // useLocale Mock gibt Label-Keys zurΓΌck
+ const button = screen.getByRole("button", { name: /imagegen.enterPrompt/ });
expect(button).toBeInTheDocument();
});
it("sollte Textarea fΓΌr Prompt haben", () => {
render();
- const textarea = screen.getByPlaceholderText("Describe your image in detail...");
+ const textarea = screen.getByPlaceholderText("imagegen.promptPlaceholder");
expect(textarea).toBeInTheDocument();
expect(textarea.tagName.toLowerCase()).toBe("textarea");
});
@@ -171,31 +172,28 @@ describe("ImageGenerator Reference Image Integration", () => {
it("sollte Standard-Placeholder zeigen wenn kein Reference Image hochgeladen ist", () => {
render();
- // Sollte Standard-Placeholder zeigen (tatsΓ€chlicher Text aus Locale)
- const textarea = screen.getByPlaceholderText("Describe your image in detail...");
+ // useLocale Mock gibt Label-Keys zurΓΌck
+ const textarea = screen.getByPlaceholderText("imagegen.promptPlaceholder");
expect(textarea).toBeInTheDocument();
// Sollte NICHT Edit-Placeholder zeigen
- expect(
- screen.queryByPlaceholderText("Describe changes you want to make to the image..."),
- ).not.toBeInTheDocument();
+ expect(screen.queryByPlaceholderText("imagegen.editPromptPlaceholder")).not.toBeInTheDocument();
});
it("sollte Button Text korrekt Γ€ndern basierend auf previewState", () => {
render();
- // Initial: "Enter a prompt to create" Button (wenn previewState "empty" ist und kein prompt)
- const button = screen.getByRole("button", { name: /Enter a prompt to create/ });
+ // useLocale Mock gibt Label-Keys zurΓΌck
+ const button = screen.getByRole("button", { name: /imagegen.enterPrompt/ });
expect(button).toBeInTheDocument();
});
it("sollte Edit-Mode Locale Keys korrekt geladen haben", () => {
- // Test dass die Locale Keys verfΓΌgbar sind durch tatsΓ€chliche Werte
render();
- // Verifiziere dass Standard-Placeholder korrekt geladen ist
- const textarea = screen.getByPlaceholderText("Describe your image in detail...");
- expect(textarea).toHaveAttribute("placeholder", "Describe your image in detail...");
+ // useLocale Mock gibt Label-Keys zurΓΌck
+ const textarea = screen.getByPlaceholderText("imagegen.promptPlaceholder");
+ expect(textarea).toHaveAttribute("placeholder", "imagegen.promptPlaceholder");
// Die dynamische Placeholder-Logik ist implementiert (wird bei previewState === "reference" aktiv)
expect(textarea).toBeInTheDocument();
@@ -210,7 +208,7 @@ describe("ImageGenerator Reference Image Integration", () => {
expect(dropZone).toBeInTheDocument();
// Referenz Image Upload Bereich sollte vorhanden sein
- const uploadSection = screen.getByText("Upload Reference Image (Optional)");
+ const uploadSection = screen.getByText("imagegen.uploadReferenceImage");
expect(uploadSection).toBeInTheDocument();
});
});
diff --git a/website/test/ImageGenerator.test.tsx b/website/test/ImageGenerator.test.tsx
index 53ac5c7f6..ac5079d71 100644
--- a/website/test/ImageGenerator.test.tsx
+++ b/website/test/ImageGenerator.test.tsx
@@ -33,7 +33,7 @@ import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ImageGenerator } from "../components/ImageGenerator";
-import { useAccount, useSwitchChain, useChainId, useConnect, useWalletClient } from "wagmi";
+import { useAccount, useConnect, useWalletClient } from "wagmi";
// No need to mock getChain - it's just reading env vars and returning constants
// The real implementation works fine in tests
@@ -107,18 +107,20 @@ describe("ImageGenerator Component", () => {
it("should trigger wallet connection when expand button is clicked", async () => {
// Start in disconnected state (collapsed)
+ // Create a new mock that we can track
const mockConnect = vi.fn();
- vi.mocked(useConnect).mockReturnValueOnce({
- connect: mockConnect,
- connectors: [{ id: "mockConnector", name: "Mock Wallet" }],
- } as unknown as ReturnType);
- vi.mocked(useAccount).mockReturnValueOnce({
+ vi.mocked(useAccount).mockReturnValue({
address: undefined,
isConnected: false,
status: "disconnected",
} as ReturnType);
+ vi.mocked(useConnect).mockReturnValue({
+ connect: mockConnect,
+ connectors: [{ id: "mockConnector", name: "Mock Wallet" }],
+ } as unknown as ReturnType);
+
render();
// Verify we start in collapsed state
@@ -166,62 +168,7 @@ describe("ImageGenerator Component", () => {
});
});
- it("should call switchChain when user attempts to create artwork on wrong network", async () => {
- const mockSwitchChain = vi.fn().mockResolvedValue(undefined);
-
- // Override the centralized mocks for this specific test
- // Mock a connected wallet on the wrong chain (Ethereum mainnet = 1)
- vi.mocked(useAccount).mockReturnValue({
- address: "0x1234567890123456789012345678901234567890" as `0x${string}`,
- isConnected: true,
- status: "connected",
- } as ReturnType);
-
- // Mock wallet client for x402 hook
-
- vi.mocked(useWalletClient).mockReturnValue({
- data: {
- account: { address: "0x1234567890123456789012345678901234567890" },
- signTypedData: vi.fn(),
- },
- } as ReturnType);
-
- // Mock that user is on wrong chain (Ethereum mainnet instead of Optimism)
- vi.mocked(useChainId).mockReturnValue(1);
-
- // Mock the switchChainAsync function (used by the x402 flow)
- vi.mocked(useSwitchChain).mockReturnValue({
- switchChain: mockSwitchChain,
- switchChainAsync: mockSwitchChain,
- isPending: false,
- chains: [{ id: 10, name: "Optimism" }],
- } as unknown as ReturnType);
-
- render();
-
- // Fill in the prompt
- const textarea = screen.getByPlaceholderText("mocked-imagegen.promptPlaceholder");
- fireEvent.change(textarea, { target: { value: "Test artwork prompt" } });
-
- // Find and click the create artwork button
- // The button text might vary based on wallet state, so we look for common patterns
- const buttons = screen.getAllByRole("button");
- const createButton = buttons.find(
- (button) =>
- button.textContent?.toLowerCase().includes("create") ||
- button.textContent?.toLowerCase().includes("artwork") ||
- button.textContent?.toLowerCase().includes("mint"),
- );
-
- if (createButton) {
- fireEvent.click(createButton);
-
- // Verify that switchChainAsync was called with Optimism chain ID (10)
- expect(mockSwitchChain).toHaveBeenCalledWith({ chainId: 10 });
- } else {
- // If no create button is found, at least verify the mock is set up correctly
- expect(mockSwitchChain).toBeDefined();
- // This is a fallback test - the real test depends on the component's current state logic
- }
- });
+ // Note: Chain switching behavior is tested in useAutoNetwork.test.ts
+ // The switchIfNeeded() function is called before transactions and is
+ // thoroughly covered there (see "switchIfNeeded() should switch when on wrong network")
});
diff --git a/website/test/MyNFTList.test.tsx b/website/test/MyNFTList.test.tsx
index b95b8e18b..5e4a382c5 100644
--- a/website/test/MyNFTList.test.tsx
+++ b/website/test/MyNFTList.test.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, waitFor } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, waitFor, screen, cleanup } from "@testing-library/react";
import { MyNFTList } from "../components/MyNFTList";
/**
@@ -8,9 +8,6 @@ import { MyNFTList } from "../components/MyNFTList";
* in useEffect dependencies after the "Lint hooks" commit.
*/
-let renderCount = 0;
-let contractConfigCallCount = 0;
-
// Mock wagmi functions
const mockUseAccount = vi.fn();
const mockUseReadContract = vi.fn();
@@ -25,14 +22,21 @@ vi.mock("wagmi/actions", () => ({
readContract: (...args: unknown[]) => mockReadContract(...args),
}));
-// Mock getChain to provide the stable constant
-vi.mock("../utils/getChain", () => ({
- getChain: vi.fn(() => ({ id: 10 })),
- // Stable constant that our components now use
- genAiNFTContractConfig: {
- address: "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb",
- abi: [], // Simplified for test
- },
+// Mock useAutoNetwork hook - returns object with network and switchIfNeeded
+vi.mock("../hooks/useAutoNetwork", () => ({
+ useAutoNetwork: vi.fn(() => ({
+ network: "eip155:10", // Default to Optimism mainnet
+ isOnCorrectNetwork: true,
+ switchIfNeeded: vi.fn(() => Promise.resolve(true)),
+ })),
+}));
+
+// Mock chain-utils
+vi.mock("@fretchen/chain-utils", () => ({
+ getGenAiNFTAddress: vi.fn(() => "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb"),
+ GenImNFTv4ABI: [],
+ GENAI_NFT_NETWORKS: ["eip155:10", "eip155:11155420"],
+ fromCAIP2: vi.fn((network: string) => parseInt(network.split(":")[1])),
}));
// Mock config
@@ -68,17 +72,9 @@ vi.mock("../hooks/useLocale", () => ({
useLocale: vi.fn(() => "Mocked text"),
}));
-// Create a wrapper component to count renders
-function MyNFTListWrapper(props: Record) {
- renderCount++;
- return ;
-}
-
describe("MyNFTList Re-render Bug Reproduction", () => {
beforeEach(() => {
vi.clearAllMocks();
- renderCount = 0;
- contractConfigCallCount = 0;
// Setup default mock returns
mockUseAccount.mockReturnValue({
@@ -103,76 +99,69 @@ describe("MyNFTList Re-render Bug Reproduction", () => {
});
});
- it("should show the bug is fixed with stable constants", async () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("should call balanceOf ABI function when loading NFTs", async () => {
+ // Render the component
+ render();
+
+ // Wait for the component to load
+ await waitFor(() => {
+ // balanceOf should be called via useReadContract (mocked above)
+ expect(mockUseReadContract).toHaveBeenCalled();
+ });
+ });
+
+ it("should call tokenOfOwnerByIndex ABI function for each NFT", async () => {
// Render the component
- render();
+ render();
- // Wait a bit to let effects run
+ // Wait for the component to load and fetch token IDs
await waitFor(
() => {
- // With stable constants, only a few renders should occur
- expect(renderCount).toBeLessThanOrEqual(3);
+ // tokenOfOwnerByIndex should be called for each NFT in the balance
+ expect(mockReadContract).toHaveBeenCalled();
},
{ timeout: 2000 },
);
- // Die instabile contractConfig Funktion sollte nicht mehr aufgerufen werden
- // da die Komponenten jetzt die stabile Konstante verwenden
- expect(contractConfigCallCount).toBe(0);
-
- console.log(`Total renders: ${renderCount}`);
- console.log(`Contract config calls: ${contractConfigCallCount}`);
- });
-
- it("should show that stable constant has consistent reference", async () => {
- // Import the new stable constant
- const { genAiNFTContractConfig } = await import("../utils/getChain");
-
- // Multiple imports should return the same reference
- const config1 = genAiNFTContractConfig;
- const config2 = genAiNFTContractConfig;
-
- // They should be exactly the same object (same content AND reference)
- expect(config1).toEqual(config2); // Content is the same
- expect(config1).toBe(config2); // And references are the same - THIS IS THE FIX!
+ // Verify tokenOfOwnerByIndex was called with correct function name
+ const calls = mockReadContract.mock.calls;
+ const tokenOfOwnerByIndexCalls = calls.filter((call: unknown[]) => {
+ const params = call[1] as { functionName?: string };
+ return params?.functionName === "tokenOfOwnerByIndex";
+ });
- // This proves why the useEffect with contract config in dependencies
- // causes infinite re-renders
+ expect(tokenOfOwnerByIndexCalls.length).toBeGreaterThan(0);
});
- it("should demonstrate stable dependency with fixed implementation", async () => {
- let effectRunCount = 0;
-
- // Create a component that uses the stable constant
- function FixedComponent() {
- const [contractConfig, setContractConfig] = React.useState | null>(null);
-
- React.useEffect(() => {
- import("../utils/getChain").then(({ genAiNFTContractConfig }) => {
- setContractConfig(genAiNFTContractConfig);
- });
- }, []);
-
- React.useEffect(() => {
- effectRunCount++;
- console.log(`Effect run #${effectRunCount}`);
- // Simulate loading token IDs
- }, [contractConfig]); // This dependency causes the loop!
-
- return Component
;
- }
-
- render();
+ it("should render NFT cards after loading token IDs", async () => {
+ render();
+ // Wait for NFT cards to appear
await waitFor(
() => {
- // With stable constants, the effect only runs 2 times
- // (once initial, once after the first useEffect update)
- expect(effectRunCount).toBeLessThanOrEqual(2);
+ expect(screen.getByTestId("nft-card-1")).toBeInTheDocument();
+ expect(screen.getByTestId("nft-card-2")).toBeInTheDocument();
},
- { timeout: 1000 },
+ { timeout: 2000 },
);
+ });
- console.log(`Effect ran ${effectRunCount} times with stable dependency`);
+ it("should show empty state when user has no NFTs", async () => {
+ // Mock no NFTs
+ mockUseReadContract.mockReturnValue({
+ data: 0n,
+ isLoading: false,
+ });
+
+ render();
+
+ // Should not show any NFT cards
+ await waitFor(() => {
+ expect(screen.queryByTestId("nft-card-1")).not.toBeInTheDocument();
+ });
});
});
diff --git a/website/test/PublicNFTList.test.tsx b/website/test/PublicNFTList.test.tsx
index 95610b87c..d485dcafc 100644
--- a/website/test/PublicNFTList.test.tsx
+++ b/website/test/PublicNFTList.test.tsx
@@ -1,25 +1,38 @@
import React from "react";
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { PublicNFTList } from "../components/PublicNFTList";
/**
* Mock complex dependencies to focus on component logic
*/
-vi.mock("viem", () => ({
- createPublicClient: vi.fn(() => ({
- readContract: vi.fn(),
+
+// Mock useAutoNetwork - must return object with network
+vi.mock("../hooks/useAutoNetwork", () => ({
+ useAutoNetwork: vi.fn(() => ({
+ network: "eip155:10",
+ isOnCorrectNetwork: true,
+ switchIfNeeded: vi.fn(() => Promise.resolve(true)),
})),
- http: vi.fn(),
}));
-vi.mock("viem/chains", () => ({
- optimism: { id: 10, name: "Optimism" },
+// Mock useConfiguredPublicClient - returns a mock client
+const mockReadContract = vi.fn();
+vi.mock("../hooks/useConfiguredPublicClient", () => ({
+ useConfiguredPublicClient: vi.fn(() => ({
+ readContract: mockReadContract,
+ })),
}));
-// No need to mock getChain - it's just reading env vars and returning constants
+// Mock chain-utils
+vi.mock("@fretchen/chain-utils", () => ({
+ getGenAiNFTAddress: vi.fn(() => "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb"),
+ GenImNFTv4ABI: [{ name: "getAllPublicTokens", type: "function", inputs: [], outputs: [{ type: "uint256[]" }] }],
+ GENAI_NFT_NETWORKS: ["eip155:10", "eip155:11155420"],
+}));
vi.mock("../components/NFTCard", () => ({
- NFTCard: vi.fn(() => Mock NFT Card
),
+ NFTCard: vi.fn(({ tokenId }) => NFT {tokenId.toString()}
),
}));
vi.mock("../components/ImageModal", () => ({
@@ -32,11 +45,16 @@ vi.mock("../layouts/styles", () => ({
loadingContainer: "loading-container",
emptyStateContainer: "empty-state-container",
emptyStateText: "empty-state-text",
+ galleryGrid: "gallery-grid",
grid: "nft-grid",
},
spinner: "spinner",
}));
+vi.mock("../hooks/useLocale", () => ({
+ useLocale: vi.fn(() => "Mocked text"),
+}));
+
global.fetch = vi.fn();
/**
@@ -46,6 +64,12 @@ global.fetch = vi.fn();
describe("PublicNFTList Component", () => {
beforeEach(() => {
vi.clearAllMocks();
+ // Default: return empty array (no public NFTs)
+ mockReadContract.mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ cleanup();
});
/**
@@ -56,78 +80,87 @@ describe("PublicNFTList Component", () => {
});
/**
- * Tests component structure and React element creation
+ * CRITICAL: Tests that component actually renders without errors
+ * This catches ABI mismatches like missing getAllPublicTokens function
*/
- it("should be a React component", () => {
- expect(PublicNFTList).toBeDefined();
- expect(typeof PublicNFTList).toBe("function");
+ describe("Rendering (Bug Prevention)", () => {
+ it("should render loading state initially", () => {
+ // Don't resolve the promise immediately
+ mockReadContract.mockReturnValue(new Promise(() => {}));
+
+ render();
+
+ expect(screen.getByText(/Loading public artworks/i)).toBeInTheDocument();
+ });
+
+ it("should render empty state when no public NFTs", async () => {
+ mockReadContract.mockResolvedValue([]);
+
+ render();
+
+ // Wait for loading to finish
+ await waitFor(() => {
+ expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument();
+ });
+ });
+
+ it("should render NFT cards when public NFTs exist", async () => {
+ mockReadContract.mockResolvedValue([1n, 2n, 3n]);
+
+ render();
+
+ // Wait for NFTs to load
+ await waitFor(() => {
+ expect(screen.getByTestId("nft-card-3")).toBeInTheDocument();
+ expect(screen.getByTestId("nft-card-2")).toBeInTheDocument();
+ expect(screen.getByTestId("nft-card-1")).toBeInTheDocument();
+ });
+ });
+
+ it("should call getAllPublicTokens ABI function", async () => {
+ mockReadContract.mockResolvedValue([1n, 2n]);
+
+ render();
+
+ await waitFor(() => {
+ // Verify the correct ABI function is called
+ expect(mockReadContract).toHaveBeenCalledWith(
+ expect.objectContaining({
+ functionName: "getAllPublicTokens",
+ }),
+ );
+ });
+ });
+
+ it("should handle contract errors gracefully", async () => {
+ mockReadContract.mockRejectedValue(new Error("Contract call failed"));
+
+ // Should not throw
+ expect(() => render()).not.toThrow();
+
+ // Wait for error handling
+ await waitFor(() => {
+ expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument();
+ });
+ });
});
/**
- * Tests props interface compatibility
+ * Tests component structure and React element creation
*/
- it("should accept correct props interface", () => {
- expect(() => {
- const element = React.createElement(PublicNFTList, {});
- expect(element).toBeDefined();
- }).not.toThrow();
+ it("should be a React component", () => {
+ expect(PublicNFTList).toBeDefined();
+ expect(typeof PublicNFTList).toBe("function");
});
/**
* Tests className prop from BaseComponentProps
*/
- it("should accept className prop", () => {
- expect(() => {
- const element = React.createElement(PublicNFTList, { className: "custom-class" });
- expect(element).toBeDefined();
- }).not.toThrow();
- });
-
- /**
- * Tests that component is designed for wallet independence
- */
- it("should be designed for wallet independence", () => {
- // PublicNFTList should work without wallet connection
- // This is tested by the fact that it can be created without wallet-related props
- expect(() => {
- const element = React.createElement(PublicNFTList, {});
- expect(element).toBeDefined();
- }).not.toThrow();
- });
-
- /**
- * Tests component with optional props
- */
- it("should handle optional props", () => {
- const propsWithOptional = {
- className: "custom-public-nft-list",
- };
-
- expect(() => {
- const element = React.createElement(PublicNFTList, propsWithOptional);
- expect(element).toBeDefined();
- }).not.toThrow();
- });
-
- /**
- * Tests architectural requirement: uses viem public client
- */
- it("should use viem public client architecture", async () => {
- // Verify that the component imports and could use createPublicClient
- const { createPublicClient } = await import("viem");
- expect(createPublicClient).toBeDefined();
- expect(typeof createPublicClient).toBe("function");
- });
-
- /**
- * Tests that component doesn't require wallet dependencies
- */
- it("should not depend on wagmi wallet hooks", () => {
- // The component should be importable and creatable without wallet hooks
- // This validates the architectural decision to use public clients only
- expect(() => {
- const element = React.createElement(PublicNFTList, {});
- expect(element).toBeDefined();
- }).not.toThrow();
+ it("should accept className prop", async () => {
+ render();
+ // Wait for async operations to complete
+ await waitFor(() => {
+ expect(screen.queryByText(/Loading/i)).not.toBeInTheDocument();
+ });
});
});
diff --git a/website/test/WalletOptions.test.tsx b/website/test/WalletOptions.test.tsx
index d9add3db9..313a5bd11 100644
--- a/website/test/WalletOptions.test.tsx
+++ b/website/test/WalletOptions.test.tsx
@@ -112,7 +112,7 @@ describe("WalletOptions Component", () => {
it("always shows 'Connect Account' initially (hydration safety)", () => {
render();
- expect(screen.getByRole("button")).toHaveTextContent("Connect Account");
+ expect(screen.getByRole("button")).toHaveTextContent("walletoptions.connectAccount");
});
it("prevents hydration mismatch when wallet is connected", async () => {
diff --git a/website/test/setup.ts b/website/test/setup.ts
index 8844b0b3a..027187b6d 100644
--- a/website/test/setup.ts
+++ b/website/test/setup.ts
@@ -2,55 +2,61 @@ import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
-// Mock wagmi hooks that are used in components
+// =============================================================================
+// EXPORTABLE MOCK FUNCTIONS
+// Tests can import these and configure them per test/describe block
+// =============================================================================
+
+// Chain & Network Mocks
+export const mockChainId = vi.fn(() => 10); // Default: Optimism
+export const mockSwitchChainAsync = vi.fn().mockResolvedValue(undefined);
+
+// Account Mocks
+export const mockAccountData = vi.fn(() => ({
+ address: "0x123456789abcdef" as `0x${string}`,
+ isConnected: false,
+ status: "disconnected" as const,
+ isConnecting: false,
+ isDisconnected: true,
+ isReconnecting: false,
+}));
+
+// Contract Mocks
+export const mockReadContractData = vi.fn(() => ({
+ data: undefined,
+ error: null,
+ isPending: false,
+ refetch: vi.fn(),
+}));
+
+export const mockWriteContractData = vi.fn(() => ({
+ writeContract: vi.fn(),
+ writeContractAsync: vi.fn(),
+ isPending: false,
+ error: null,
+}));
+
+// =============================================================================
+// WAGMI MOCK SETUP - Uses the exportable functions above
+// =============================================================================
+
vi.mock("wagmi", () => ({
- useAccount: vi.fn(() => ({
- address: "0x123456789abcdef",
- isConnected: false,
- })),
- useWalletClient: vi.fn(() => ({
- data: undefined,
- })),
- useSignMessage: vi.fn(() => ({
- signMessageAsync: vi.fn(),
- })),
- useReadContract: vi.fn(() => ({
- data: undefined,
- error: null,
- isPending: false,
- refetch: vi.fn(),
- })),
- useReadContracts: vi.fn(() => ({
- data: undefined,
- error: null,
- isPending: false,
- refetch: vi.fn(),
- })),
- useWriteContract: vi.fn(() => ({
- writeContract: vi.fn(),
- writeContractAsync: vi.fn(),
- isPending: false,
- error: null,
- })),
- useWaitForTransactionReceipt: vi.fn(() => ({
- isLoading: false,
- isSuccess: false,
- })),
- useChainId: vi.fn(() => 10),
+ useAccount: vi.fn(() => mockAccountData()),
+ useWalletClient: vi.fn(() => ({ data: undefined })),
+ useSignMessage: vi.fn(() => ({ signMessageAsync: vi.fn() })),
+ useReadContract: vi.fn(() => mockReadContractData()),
+ useReadContracts: vi.fn(() => mockReadContractData()),
+ useWriteContract: vi.fn(() => mockWriteContractData()),
+ useWaitForTransactionReceipt: vi.fn(() => ({ isLoading: false, isSuccess: false })),
+ useChainId: vi.fn(() => mockChainId()),
useSwitchChain: vi.fn(() => ({
switchChain: vi.fn(),
+ switchChainAsync: mockSwitchChainAsync,
chains: [],
})),
- useConnect: vi.fn(() => ({
- connectors: [],
- connect: vi.fn(),
- })),
- useDisconnect: vi.fn(() => ({
- disconnect: vi.fn(),
- })),
- useEnsName: vi.fn(() => ({
- data: null,
- })),
+ useConnect: vi.fn(() => ({ connectors: [], connect: vi.fn() })),
+ useDisconnect: vi.fn(() => ({ disconnect: vi.fn() })),
+ useEnsName: vi.fn(() => ({ data: null })),
createConfig: vi.fn(() => ({})),
http: vi.fn(),
WagmiProvider: vi.fn(({ children }) => children),
@@ -79,18 +85,21 @@ vi.mock("vike-react/usePageContext", () => ({
})),
}));
-// No need to mock utils/getChain - it's just reading env vars and returning constants
-// The real implementation works fine in tests and ensures we test realistic configurations
-
// Mock useLocale hook
-vi.mock("./hooks/useLocale", () => ({
+vi.mock("../hooks/useLocale", () => ({
useLocale: vi.fn(({ label }: { label: string }) => label),
}));
-// Import wagmi at top level for mock utilities
-import { useAccount } from "wagmi";
+// NOTE: useAutoNetwork is NOT mocked here anymore!
+// Tests that need specific useAutoNetwork behavior should:
+// 1. Configure mockChainId() for the desired chain
+// 2. Let the real hook run with mocked wagmi hooks
-// Reusable mock data for tests
+// =============================================================================
+// HELPER FUNCTIONS FOR TESTS
+// =============================================================================
+
+// Reusable mock data
export const MOCK_CONNECTED_ACCOUNT = {
address: "0x1234567890123456789012345678901234567890" as `0x${string}`,
isConnected: true,
@@ -98,7 +107,7 @@ export const MOCK_CONNECTED_ACCOUNT = {
isConnecting: false,
isDisconnected: false,
isReconnecting: false,
-} as ReturnType;
+};
export const MOCK_DISCONNECTED_ACCOUNT = {
address: undefined,
@@ -107,18 +116,34 @@ export const MOCK_DISCONNECTED_ACCOUNT = {
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
-} as ReturnType;
+};
+
+// Helper to set chain for a test
+export const mockChain = (chainId: number) => {
+ mockChainId.mockReturnValue(chainId);
+};
-// Reusable mock utilities for tests
+// Helper to set connected wallet
export const mockConnectedWallet = () => {
- vi.mocked(useAccount).mockReturnValue(MOCK_CONNECTED_ACCOUNT);
+ mockAccountData.mockReturnValue(MOCK_CONNECTED_ACCOUNT);
};
export const mockDisconnectedWallet = () => {
- vi.mocked(useAccount).mockReturnValue(MOCK_DISCONNECTED_ACCOUNT);
+ mockAccountData.mockReturnValue(MOCK_DISCONNECTED_ACCOUNT);
};
// Clean up after each test
afterEach(() => {
cleanup();
+ // Reset all mock return values to defaults
+ mockChainId.mockReturnValue(10);
+ mockAccountData.mockReturnValue({
+ address: "0x123456789abcdef" as `0x${string}`,
+ isConnected: false,
+ status: "disconnected" as const,
+ isConnecting: false,
+ isDisconnected: true,
+ isReconnecting: false,
+ });
+ mockSwitchChainAsync.mockResolvedValue(undefined);
});
diff --git a/website/test/useAutoNetwork.test.ts b/website/test/useAutoNetwork.test.ts
new file mode 100644
index 000000000..09312ef53
--- /dev/null
+++ b/website/test/useAutoNetwork.test.ts
@@ -0,0 +1,339 @@
+/**
+ * Tests for useAutoNetwork hook using real wagmi with mock connector
+ *
+ * Uses wagmi's mock connector to test with different chainIds in one file.
+ * See: https://wagmi.sh/react/api/connectors/mock
+ *
+ * IMPORTANT: The mock connector shares state between tests, so each test
+ * must first reset to a known chain state before asserting.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { createElement } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+// Disable global wagmi mocks from setup.ts
+vi.unmock("wagmi");
+vi.unmock("wagmi/connectors");
+
+import { createConfig, WagmiProvider, http, useChainId, useSwitchChain, useAccount, useConnect } from "wagmi";
+import { optimism, optimismSepolia, base, mainnet } from "wagmi/chains";
+import { mock } from "wagmi/connectors";
+import { useAutoNetwork } from "../hooks/useAutoNetwork";
+
+// Test networks in CAIP-2 format
+const OPTIMISM_MAINNET = "eip155:10";
+const OPTIMISM_SEPOLIA = "eip155:11155420";
+const BASE_MAINNET = "eip155:8453";
+
+// Shared config - state persists between tests
+const sharedConfig = createConfig({
+ chains: [optimism, optimismSepolia, base, mainnet],
+ connectors: [
+ mock({
+ accounts: ["0x1234567890123456789012345678901234567890"],
+ features: { defaultConnected: true },
+ }),
+ ],
+ transports: {
+ [optimism.id]: http(),
+ [optimismSepolia.id]: http(),
+ [base.id]: http(),
+ [mainnet.id]: http(),
+ },
+});
+
+// Create test wrapper with shared config
+function createTestWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ const TestWrapper = ({ children }: { children: React.ReactNode }) =>
+ createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ createElement(WagmiProvider, { config: sharedConfig }, children),
+ );
+ TestWrapper.displayName = "TestWrapper";
+ return TestWrapper;
+}
+
+describe("useAutoNetwork Hook", () => {
+ describe("return value structure", () => {
+ it("should return network, isOnCorrectNetwork, and switchIfNeeded", async () => {
+ const wrapper = createTestWrapper();
+ const supportedNetworks = [OPTIMISM_MAINNET, OPTIMISM_SEPOLIA];
+
+ const { result } = renderHook(() => useAutoNetwork(supportedNetworks), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.network).toBeDefined();
+ });
+
+ expect(result.current).toHaveProperty("network");
+ expect(result.current).toHaveProperty("isOnCorrectNetwork");
+ expect(result.current).toHaveProperty("switchIfNeeded");
+ expect(typeof result.current.switchIfNeeded).toBe("function");
+ });
+ });
+
+ describe("when on Optimism mainnet (chainId=10)", () => {
+ it("should return eip155:10 as current network", async () => {
+ const wrapper = createTestWrapper();
+ const supportedNetworks = [OPTIMISM_MAINNET, OPTIMISM_SEPOLIA];
+
+ const { result } = renderHook(() => useAutoNetwork(supportedNetworks), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.network).toBe(OPTIMISM_MAINNET);
+ });
+
+ expect(result.current.isOnCorrectNetwork).toBe(true);
+ });
+
+ it("switchIfNeeded() should return true without switching", async () => {
+ const wrapper = createTestWrapper();
+ const supportedNetworks = [OPTIMISM_MAINNET];
+
+ const { result } = renderHook(() => useAutoNetwork(supportedNetworks), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.network).toBeDefined();
+ });
+
+ let switched = false;
+ await act(async () => {
+ switched = await result.current.switchIfNeeded();
+ });
+
+ expect(switched).toBe(true);
+ });
+ });
+
+ describe("when on Optimism Sepolia (chainId=11155420)", () => {
+ it("should return eip155:11155420 as current network", async () => {
+ const wrapper = createTestWrapper();
+
+ // ALL hooks in ONE renderHook - same React tree
+ const { result } = renderHook(
+ () => ({
+ autoNetwork: useAutoNetwork([OPTIMISM_MAINNET, OPTIMISM_SEPOLIA]),
+ chainId: useChainId(),
+ switchChain: useSwitchChain(),
+ }),
+ { wrapper },
+ );
+
+ // Wait for initial render
+ await waitFor(() => {
+ expect(result.current.chainId).toBe(10);
+ });
+
+ // Switch to Sepolia
+ await act(async () => {
+ result.current.switchChain.switchChain({ chainId: 11155420 });
+ });
+
+ await waitFor(() => {
+ expect(result.current.chainId).toBe(11155420);
+ });
+
+ expect(result.current.autoNetwork.network).toBe(OPTIMISM_SEPOLIA);
+ expect(result.current.autoNetwork.isOnCorrectNetwork).toBe(true);
+ });
+ });
+
+ describe("when on Base mainnet (chainId=8453)", () => {
+ it("should return eip155:8453 as current network when Base is supported", async () => {
+ const wrapper = createTestWrapper();
+
+ const { result } = renderHook(
+ () => ({
+ autoNetwork: useAutoNetwork([BASE_MAINNET, OPTIMISM_MAINNET]),
+ switchChain: useSwitchChain(),
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.autoNetwork.network).toBeDefined();
+ });
+
+ // Switch to Base
+ await act(async () => {
+ result.current.switchChain.switchChain({ chainId: 8453 });
+ });
+
+ await waitFor(() => {
+ expect(result.current.autoNetwork.network).toBe(BASE_MAINNET);
+ expect(result.current.autoNetwork.isOnCorrectNetwork).toBe(true);
+ });
+ });
+ });
+
+ describe("when on unsupported network (chainId=1 Ethereum)", () => {
+ it("should return default network (first in list)", async () => {
+ const wrapper = createTestWrapper();
+
+ const { result } = renderHook(
+ () => ({
+ autoNetwork: useAutoNetwork([OPTIMISM_MAINNET, OPTIMISM_SEPOLIA]),
+ switchChain: useSwitchChain(),
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.autoNetwork.network).toBeDefined();
+ });
+
+ // Switch to Ethereum mainnet (unsupported)
+ await act(async () => {
+ result.current.switchChain.switchChain({ chainId: 1 });
+ });
+
+ await waitFor(() => {
+ // Should return default (first supported network)
+ expect(result.current.autoNetwork.network).toBe(OPTIMISM_MAINNET);
+ expect(result.current.autoNetwork.isOnCorrectNetwork).toBe(false);
+ });
+ });
+
+ it("switchIfNeeded() should switch to default network", async () => {
+ const wrapper = createTestWrapper();
+
+ const { result } = renderHook(
+ () => ({
+ autoNetwork: useAutoNetwork([OPTIMISM_MAINNET, OPTIMISM_SEPOLIA]),
+ switchChain: useSwitchChain(),
+ chainId: useChainId(),
+ account: useAccount(),
+ connect: useConnect(),
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.autoNetwork.network).toBeDefined();
+ });
+
+ // First: connect (mock connector is not auto-connected)
+ await act(async () => {
+ result.current.connect.connect({ connector: result.current.connect.connectors[0] });
+ });
+ await waitFor(() => {
+ expect(result.current.account.isConnected).toBe(true);
+ });
+
+ // Reset to Optimism (in case previous tests left us elsewhere)
+ await act(async () => {
+ result.current.switchChain.switchChain({ chainId: 10 });
+ });
+ await waitFor(() => {
+ expect(result.current.chainId).toBe(10);
+ });
+
+ // Switch to Ethereum mainnet (unsupported)
+ await act(async () => {
+ result.current.switchChain.switchChain({ chainId: 1 });
+ });
+
+ await waitFor(() => {
+ expect(result.current.chainId).toBe(1);
+ });
+
+ // Now call switchIfNeeded - should switch back to Optimism
+ await act(async () => {
+ const switched = await result.current.autoNetwork.switchIfNeeded();
+ expect(switched).toBe(true);
+ });
+
+ await waitFor(() => {
+ expect(result.current.chainId).toBe(10); // Optimism
+ });
+ });
+ });
+
+ describe("network switching behavior", () => {
+ it("should NOT auto-switch on mount", async () => {
+ const wrapper = createTestWrapper();
+
+ const { result } = renderHook(
+ () => ({
+ autoNetwork: useAutoNetwork([OPTIMISM_SEPOLIA]), // Only Sepolia supported
+ switchChain: useSwitchChain(),
+ chainId: useChainId(),
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.autoNetwork.network).toBeDefined();
+ });
+
+ // Reset to Optimism (in case previous tests left us elsewhere)
+ await act(async () => {
+ result.current.switchChain.switchChain({ chainId: 10 });
+ });
+ await waitFor(() => {
+ expect(result.current.chainId).toBe(10);
+ });
+
+ // Should still be on Optimism mainnet (10), NOT auto-switched
+ expect(result.current.chainId).toBe(10);
+ expect(result.current.autoNetwork.isOnCorrectNetwork).toBe(false);
+ });
+
+ it("switchIfNeeded() should switch when on wrong network", async () => {
+ const wrapper = createTestWrapper();
+
+ const { result } = renderHook(
+ () => ({
+ autoNetwork: useAutoNetwork([OPTIMISM_SEPOLIA]), // Only Sepolia supported
+ switchChain: useSwitchChain(),
+ chainId: useChainId(),
+ account: useAccount(),
+ connect: useConnect(),
+ }),
+ { wrapper },
+ );
+
+ await waitFor(() => {
+ expect(result.current.autoNetwork.network).toBeDefined();
+ });
+
+ // First: connect (mock connector is not auto-connected)
+ await act(async () => {
+ result.current.connect.connect({ connector: result.current.connect.connectors[0] });
+ });
+ await waitFor(() => {
+ expect(result.current.account.isConnected).toBe(true);
+ });
+
+ // Reset to Optimism (in case previous tests left us elsewhere)
+ await act(async () => {
+ result.current.switchChain.switchChain({ chainId: 10 });
+ });
+ await waitFor(() => {
+ expect(result.current.chainId).toBe(10);
+ });
+
+ // Currently on Optimism (10), but only Sepolia supported
+ expect(result.current.chainId).toBe(10);
+ expect(result.current.autoNetwork.isOnCorrectNetwork).toBe(false);
+
+ // Call switchIfNeeded
+ await act(async () => {
+ const switched = await result.current.autoNetwork.switchIfNeeded();
+ expect(switched).toBe(true);
+ });
+
+ // Should now be on Sepolia
+ await waitFor(() => {
+ expect(result.current.chainId).toBe(11155420);
+ expect(result.current.autoNetwork.isOnCorrectNetwork).toBe(true);
+ });
+ });
+ });
+});
diff --git a/website/test/useConfiguredPublicClient.test.ts b/website/test/useConfiguredPublicClient.test.ts
index 93d54b15f..1883f4177 100644
--- a/website/test/useConfiguredPublicClient.test.ts
+++ b/website/test/useConfiguredPublicClient.test.ts
@@ -2,11 +2,12 @@ import { describe, it, expect, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useConfiguredPublicClient } from "../hooks/useConfiguredPublicClient";
-// Mock the dependencies
-vi.mock("../utils/getChain", () => ({
- getChain: vi.fn(() => ({
+// Mock the dependencies - no longer need getChain
+vi.mock("@fretchen/chain-utils", () => ({
+ fromCAIP2: vi.fn((network: string) => parseInt(network.split(":")[1])),
+ getViemChain: vi.fn(() => ({
id: 10,
- name: "optimism",
+ name: "OP Mainnet",
})),
}));
@@ -27,7 +28,8 @@ vi.mock("@wagmi/core", () => ({
describe("useConfiguredPublicClient Hook", () => {
it("should return a stable client reference", () => {
- const { result, rerender } = renderHook(() => useConfiguredPublicClient());
+ // Now requires network parameter (CAIP-2 format)
+ const { result, rerender } = renderHook(() => useConfiguredPublicClient("eip155:10"));
const firstClient = result.current;
@@ -46,7 +48,8 @@ describe("useConfiguredPublicClient Hook", () => {
// Reset all mocks
vi.clearAllMocks();
- const { result, rerender } = renderHook(() => useConfiguredPublicClient());
+ // Now requires network parameter (CAIP-2 format)
+ const { result, rerender } = renderHook(() => useConfiguredPublicClient("eip155:10"));
const initialClient = result.current;
@@ -61,7 +64,8 @@ describe("useConfiguredPublicClient Hook", () => {
});
it("should prevent infinite re-render loops in useEffect dependencies", () => {
- const { result } = renderHook(() => useConfiguredPublicClient());
+ // Now requires network parameter (CAIP-2 format)
+ const { result } = renderHook(() => useConfiguredPublicClient("eip155:10"));
const client1 = result.current;
const client2 = result.current;
diff --git a/website/test/useNFTListedStatus.test.ts b/website/test/useNFTListedStatus.test.ts
new file mode 100644
index 000000000..90cd87c5a
--- /dev/null
+++ b/website/test/useNFTListedStatus.test.ts
@@ -0,0 +1,342 @@
+/**
+ * Tests for useNFTListedStatus hook
+ *
+ * This hook fetches the isListed status from the smart contract.
+ * Tests verify the contract call logic and state management.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { useNFTListedStatus } from "../hooks/useNFTListedStatus";
+
+// Mock the readContract function from wagmi/actions
+const mockReadContract = vi.fn();
+
+vi.mock("wagmi/actions", () => ({
+ readContract: (...args: unknown[]) => mockReadContract(...args),
+}));
+
+// Mock the wagmi config
+vi.mock("../wagmi.config", () => ({
+ config: { testConfig: true },
+}));
+
+// Mock chain-utils
+vi.mock("@fretchen/chain-utils", () => ({
+ getGenAiNFTAddress: vi.fn((network: string) => {
+ if (network === "eip155:10") return "0xOptimismContract";
+ if (network === "eip155:11155420") return "0xSepoliaContract";
+ return "0xUnknownContract";
+ }),
+ GenImNFTv4ABI: [{ name: "isTokenListed", type: "function" }],
+ fromCAIP2: vi.fn((network: string) => {
+ if (network === "eip155:10") return 10;
+ if (network === "eip155:11155420") return 11155420;
+ return 1;
+ }),
+}));
+
+describe("useNFTListedStatus", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("Contract Call Behavior", () => {
+ it("should call isListed contract function when enabled", async () => {
+ mockReadContract.mockResolvedValue(true);
+
+ renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ enabled: true,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockReadContract).toHaveBeenCalledWith(
+ expect.anything(), // config
+ expect.objectContaining({
+ address: "0xOptimismContract",
+ functionName: "isTokenListed",
+ args: [BigInt(42)],
+ chainId: 10,
+ }),
+ );
+ });
+ });
+
+ it("should NOT call isListed when enabled=false", async () => {
+ renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ enabled: false,
+ }),
+ );
+
+ // Wait a tick to ensure no async calls are made
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ });
+
+ expect(mockReadContract).not.toHaveBeenCalled();
+ });
+
+ it("should use correct contract address for different networks", async () => {
+ mockReadContract.mockResolvedValue(false);
+
+ // Test Optimism
+ const { unmount: unmount1 } = renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(1),
+ network: "eip155:10",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockReadContract).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ address: "0xOptimismContract",
+ chainId: 10,
+ }),
+ );
+ });
+
+ unmount1();
+ vi.clearAllMocks();
+
+ // Test Sepolia
+ renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(1),
+ network: "eip155:11155420",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockReadContract).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ address: "0xSepoliaContract",
+ chainId: 11155420,
+ }),
+ );
+ });
+ });
+ });
+
+ describe("Return Values", () => {
+ it("should return isListed=true when contract returns true", async () => {
+ mockReadContract.mockResolvedValue(true);
+
+ const { result } = renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ }),
+ );
+
+ // Initially loading
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.isListed).toBeUndefined();
+
+ await waitFor(() => {
+ expect(result.current.isListed).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeUndefined();
+ });
+ });
+
+ it("should return isListed=false when contract returns false", async () => {
+ mockReadContract.mockResolvedValue(false);
+
+ const { result } = renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.isListed).toBe(false);
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it("should return error when contract call fails", async () => {
+ mockReadContract.mockRejectedValue(new Error("Contract call failed"));
+
+ const { result } = renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.error).toBe("Contract call failed");
+ expect(result.current.isListed).toBeUndefined();
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it("should handle legacy tokens (contract reverts) gracefully without console.error", async () => {
+ // Simulate a contract revert error (legacy token without isListed support)
+ const revertError = new Error('The contract function "isListed" reverted.');
+ revertError.name = "ContractFunctionExecutionError";
+ mockReadContract.mockRejectedValue(revertError);
+
+ // Spy on console.error to verify it's NOT called for reverts
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ const { result } = renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(2), // Legacy token ID
+ network: "eip155:10",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // Should set specific error message for legacy tokens
+ expect(result.current.error).toBe("isListed not available for this token");
+ // isListed should remain undefined (not an error state, just not supported)
+ expect(result.current.isListed).toBeUndefined();
+ // Should NOT log to console.error for expected contract reverts
+ expect(consoleSpy).not.toHaveBeenCalled();
+
+ consoleSpy.mockRestore();
+ });
+
+ it("should return undefined isListed when disabled", async () => {
+ const { result } = renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ enabled: false,
+ }),
+ );
+
+ // Should immediately be undefined, not loading
+ expect(result.current.isListed).toBeUndefined();
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ describe("Optimistic Updates", () => {
+ it("should allow optimistic update via setOptimisticListed", async () => {
+ mockReadContract.mockResolvedValue(false);
+
+ const { result } = renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.isListed).toBe(false);
+ });
+
+ // Optimistically set to true (simulating user toggle)
+ act(() => {
+ result.current.setOptimisticListed(true);
+ });
+
+ expect(result.current.isListed).toBe(true);
+ });
+ });
+
+ describe("Refetch", () => {
+ it("should refetch when refetch() is called", async () => {
+ mockReadContract.mockResolvedValue(false);
+
+ const { result } = renderHook(() =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.isListed).toBe(false);
+ });
+
+ expect(mockReadContract).toHaveBeenCalledTimes(1);
+
+ // Change the mock return value
+ mockReadContract.mockResolvedValue(true);
+
+ // Refetch
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(mockReadContract).toHaveBeenCalledTimes(2);
+ expect(result.current.isListed).toBe(true);
+ });
+ });
+
+ describe("Dependency Changes", () => {
+ it("should refetch when tokenId changes", async () => {
+ mockReadContract.mockResolvedValue(true);
+
+ const { result, rerender } = renderHook(
+ ({ tokenId }) =>
+ useNFTListedStatus({
+ tokenId,
+ network: "eip155:10",
+ }),
+ { initialProps: { tokenId: BigInt(1) } },
+ );
+
+ await waitFor(() => {
+ expect(result.current.isListed).toBe(true);
+ });
+
+ expect(mockReadContract).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ args: [BigInt(1)] }));
+
+ // Change tokenId
+ rerender({ tokenId: BigInt(2) });
+
+ await waitFor(() => {
+ expect(mockReadContract).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ args: [BigInt(2)] }),
+ );
+ });
+ });
+
+ it("should fetch when enabled changes from false to true", async () => {
+ mockReadContract.mockResolvedValue(true);
+
+ // Start with enabled=false (simulating isLoading state)
+ const { result, rerender } = renderHook(
+ ({ enabled }) =>
+ useNFTListedStatus({
+ tokenId: BigInt(42),
+ network: "eip155:10",
+ enabled,
+ }),
+ { initialProps: { enabled: false } },
+ );
+
+ // Should not have called contract yet
+ expect(mockReadContract).not.toHaveBeenCalled();
+ expect(result.current.isListed).toBeUndefined();
+
+ // Simulate NFT data loaded - enabled becomes true
+ rerender({ enabled: true });
+
+ // Should now fetch and return isListed
+ await waitFor(() => {
+ expect(result.current.isListed).toBe(true);
+ });
+
+ expect(mockReadContract).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/website/utils/getChain.ts b/website/utils/getChain.ts
index 3e152c5d1..ab7528145 100644
--- a/website/utils/getChain.ts
+++ b/website/utils/getChain.ts
@@ -1,13 +1,17 @@
import { sepolia, optimism, optimismSepolia, base, baseSepolia } from "wagmi/chains";
import type { Chain } from "wagmi/chains";
import CollectorNFTv1ABI from "../../eth/abi/contracts/CollectorNFTv1.json";
-import GenImNFTv3ABI from "../../eth/abi/contracts/GenImNFTv3.json";
import SupportV2ABI from "../../eth/abi/contracts/SupportV2.json";
import LLMv1ABI from "../../eth/abi/contracts/LLMv1.json";
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Chain utilities are now in @fretchen/chain-utils
+// Import directly where needed:
+// import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils";
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
/**
* Get PUBLIC_ENV__CHAIN_NAME in Vite context (Browser)
- * Direct inline implementation for simplicity
*/
const CHAIN_NAME = import.meta.env?.PUBLIC_ENV__CHAIN_NAME || "optimism";
@@ -69,24 +73,16 @@ export function isSupportV2Chain(chainId: number): boolean {
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// Other Contract Configurations
+// Legacy Contract Configurations
+//
+// GenAI NFT: MIGRATED to chain-utils - use:
+// import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils";
+// const network = useAutoNetwork(GENAI_NFT_NETWORKS);
+// const address = getGenAiNFTAddress(network);
+//
+// collectorNFTContractConfig and llmV1ContractConfig will be migrated in Phase 3
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// Create stable contract config references at module level - computed once when module loads
-const STABLE_GENAI_NFT_CONTRACT_CONFIG = (() => {
- switch (CHAIN_NAME) {
- case "sepolia":
- return { address: "0xf18E3901D91D8a08380E37A466E6F7f6AA4BD4a6", abi: GenImNFTv3ABI } as const;
- case "optimismSepolia":
- // GenImNFTv4 deployed on 2025-12-24
- return { address: "0x10827cC42a09D0BAD2d43134C69F0e776D853D85", abi: GenImNFTv3ABI } as const;
- case "optimism":
- return { address: "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb", abi: GenImNFTv3ABI } as const;
- default:
- return { address: "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb", abi: GenImNFTv3ABI } as const;
- }
-})();
-
const STABLE_COLLECTOR_NFT_CONTRACT_CONFIG = (() => {
switch (CHAIN_NAME) {
case "sepolia":
@@ -121,11 +117,15 @@ const STABLE_LLM_V1_CONTRACT_CONFIG = (() => {
})();
// Export stable references directly - these objects never change reference
-export const genAiNFTContractConfig = STABLE_GENAI_NFT_CONTRACT_CONFIG;
+/** Phase 3: Will be migrated to chain-utils */
export const collectorNFTContractConfig = STABLE_COLLECTOR_NFT_CONTRACT_CONFIG;
+/** Out of scope: LLMv1 stays in legacy config */
export const llmV1ContractConfig = STABLE_LLM_V1_CONTRACT_CONFIG;
/**
+ * @deprecated Use useAutoNetwork() + fromCAIP2() instead for GenImNFT components.
+ * This function is still used by CollectorNFT and LLMv1 components.
+ *
* Gibt das entsprechende Chain-Objekt basierend auf der CHAIN-Umgebungsvariable zurΓΌck
* @returns Das Chain-Objekt aus wagmi/chains
*/
diff --git a/website/utils/nftLoader.ts b/website/utils/nftLoader.ts
index e5a331b1b..6f029d3dd 100644
--- a/website/utils/nftLoader.ts
+++ b/website/utils/nftLoader.ts
@@ -1,4 +1,4 @@
-import { genAiNFTContractConfig } from "./getChain";
+import { getGenAiNFTAddress, GenImNFTv4ABI } from "@fretchen/chain-utils";
import { NFTMetadata } from "../types/BlogPost";
import type { PublicClient } from "viem";
@@ -10,15 +10,22 @@ interface NFTMetadataJSON {
/**
* Simple NFT loader that fetches metadata for a single token
+ * @param tokenID The token ID to load
+ * @param publicClient The public client to use for contract calls
+ * @param network CAIP-2 network string (e.g., "eip155:10")
*/
-export async function loadNFTMetadata(tokenID: number, publicClient: PublicClient): Promise {
+export async function loadNFTMetadata(
+ tokenID: number,
+ publicClient: PublicClient,
+ network: string,
+): Promise {
try {
- // Verwende die stabile genAiNFTContractConfig Konstante
+ const contractAddress = getGenAiNFTAddress(network);
// Get token URI from contract
const tokenURIResult = await publicClient.readContract({
- address: genAiNFTContractConfig.address,
- abi: genAiNFTContractConfig.abi,
+ address: contractAddress,
+ abi: GenImNFTv4ABI,
functionName: "tokenURI",
args: [BigInt(tokenID)],
});
@@ -58,11 +65,13 @@ export async function loadNFTMetadata(tokenID: number, publicClient: PublicClien
* Load multiple NFT metadata entries with controlled concurrency
* @param tokenIDs Array of token IDs to load
* @param publicClient The public client to use for contract calls
+ * @param network CAIP-2 network string (e.g., "eip155:10")
* @param concurrency Maximum number of concurrent requests (default: 3)
*/
export async function loadMultipleNFTMetadata(
tokenIDs: number[],
publicClient: PublicClient,
+ network: string,
concurrency = 3,
): Promise> {
const results: Record = {};
@@ -71,7 +80,7 @@ export async function loadMultipleNFTMetadata(
const processBatch = async (batch: number[]): Promise => {
const promises = batch.map(async (tokenID) => {
console.log(`Loading NFT metadata for token ${tokenID}...`);
- const metadata = await loadNFTMetadata(tokenID, publicClient);
+ const metadata = await loadNFTMetadata(tokenID, publicClient, network);
if (metadata) {
results[tokenID] = metadata;
}
diff --git a/website/utils/nodeChainUtils.ts b/website/utils/nodeChainUtils.ts
index c9784cb34..4eb6f65bb 100644
--- a/website/utils/nodeChainUtils.ts
+++ b/website/utils/nodeChainUtils.ts
@@ -1,15 +1,36 @@
/**
* Node.js-specific chain utilities using pure viem
* Used for build-time operations like blog generation
+ *
+ * Chain utilities are now in @fretchen/chain-utils - import directly where needed:
+ * import { getGenAiNFTAddress, GenImNFTv4ABI } from "@fretchen/chain-utils";
*/
import { createPublicClient, http } from "viem";
import { sepolia, optimism, optimismSepolia } from "viem/chains";
import type { Chain, PublicClient } from "viem";
import CollectorNFTv1ABI from "../../eth/abi/contracts/CollectorNFTv1.json";
-import GenImNFTv3ABI from "../../eth/abi/contracts/GenImNFTv3.json";
import LLMv1ABI from "../../eth/abi/contracts/LLMv1.json";
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// SSR/Node.js specific utilities
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/**
+ * Get default CAIP-2 network for server-side rendering.
+ * Uses mainnet for production, testnet for development.
+ *
+ * @returns CAIP-2 network string (e.g., "eip155:10")
+ */
+export function getDefaultNetwork(): string {
+ const isProd = process.env.NODE_ENV === "production";
+ return isProd ? "eip155:10" : "eip155:11155420";
+}
+
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+// Legacy exports (PR 2b may refactor these)
+// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
/**
* Get environment variable in Node.js context
*/
@@ -51,25 +72,7 @@ export function createNodePublicClient(): PublicClient {
}
// Node.js contract configurations - computed once at module load
-const NODE_GENAI_NFT_CONTRACT_CONFIG = (() => {
- switch (NODE_CHAIN_NAME) {
- case "sepolia":
- return {
- address: "0xf18E3901D91D8a08380E37A466E6F7f6AA4BD4a6" as `0x${string}`,
- abi: GenImNFTv3ABI,
- } as const;
- case "optimism":
- return {
- address: "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb" as `0x${string}`,
- abi: GenImNFTv3ABI,
- } as const;
- default:
- return {
- address: "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb" as `0x${string}`,
- abi: GenImNFTv3ABI,
- } as const;
- }
-})();
+// GenAI NFT: MIGRATED to chain-utils - use getGenAiNFTAddress() + GenImNFTv4ABI
const NODE_COLLECTOR_NFT_CONTRACT_CONFIG = (() => {
switch (NODE_CHAIN_NAME) {
@@ -117,13 +120,12 @@ const NODE_LLM_V1_CONTRACT_CONFIG = (() => {
})();
// Export stable contract configurations for Node.js
+// GenAI NFT: Use chain-utils getGenAiNFTAddress() + GenImNFTv4ABI instead
export const nodeContractConfigs = {
- genAiNFT: NODE_GENAI_NFT_CONTRACT_CONFIG,
collectorNFT: NODE_COLLECTOR_NFT_CONTRACT_CONFIG,
llmV1: NODE_LLM_V1_CONTRACT_CONFIG,
} as const;
// Convenience exports for backward compatibility
-export const nodeGenAiNFTContractConfig = NODE_GENAI_NFT_CONTRACT_CONFIG;
export const nodeCollectorNFTContractConfig = NODE_COLLECTOR_NFT_CONTRACT_CONFIG;
export const nodeLlmV1ContractConfig = NODE_LLM_V1_CONTRACT_CONFIG;
diff --git a/website/utils/nodeNftLoader.ts b/website/utils/nodeNftLoader.ts
index 8efd93762..c1a5fae82 100644
--- a/website/utils/nodeNftLoader.ts
+++ b/website/utils/nodeNftLoader.ts
@@ -3,7 +3,9 @@
* Used for build-time operations like blog generation
*/
-import { createNodePublicClient, nodeGenAiNFTContractConfig } from "./nodeChainUtils";
+import { createPublicClient, http } from "viem";
+import { getDefaultNetwork } from "./nodeChainUtils";
+import { getGenAiNFTAddress, GenImNFTv4ABI, getViemChain } from "@fretchen/chain-utils";
import { NFTMetadata } from "../types/BlogPost";
interface NFTMetadataJSON {
@@ -12,6 +14,18 @@ interface NFTMetadataJSON {
image?: string;
}
+/**
+ * Create a public client for the default network
+ */
+function createDefaultPublicClient() {
+ const network = getDefaultNetwork();
+ const chain = getViemChain(network);
+ return createPublicClient({
+ chain,
+ transport: http(),
+ });
+}
+
/**
* Node.js-specific NFT metadata loader using pure viem
*/
@@ -19,13 +33,14 @@ export async function loadNFTMetadataNode(tokenID: number): Promise