Skip to content

Conversation

@alperimo
Copy link

@alperimo alperimo commented Aug 19, 2025

Universal NFT Program (Solana)

This PR introduces a secure and deterministic Universal NFT program on Solana using Anchor, with ZetaChain Gateway integration for cross-chain transfers.

Overview

  • Deterministic minting with program-side token_id management and origin tracking.
  • Two-step mint flow eliminates race conditions (reserve → mint).
  • Cross-chain: burn on Solana, message ZetaChain; mint back on Solana via authenticated on_call.
  • Strict account constraints and ATA derivation prevent spoofing and partial state.
  • Config pins both the Gateway program and the exact Gateway PDA.

Accounts and PDAs

  • UniversalNftConfig (PDA: ["universal_nft_config"]) — authority, gateway_program, gateway_pda, nonce, next_token_id, is_paused, created_at, bump.
  • MintTicket (PDA: ["mint_ticket", mint, authority]) — reserved_id, slot, token_id, used, bump.
  • NftOrigin (PDA: ["nft_origin", token_id]) — original_mint, original_metadata, original_uri, timestamps, is_on_solana, bump.
  • Metaplex PDAs — Metadata and Master Edition derived via official seeds.

Instructions (public API)

  • initialize(gateway_program)

    • Accounts: config (init), authority (signer), gateway_program (executable), gateway_pda (must be owned by gateway_program), system_program.
    • Effects: stores authority, gateway_program, gateway_pda; emits ProgramConfigured.
  • reserve_next_token_id()

    • Accounts: config (mut), ticket (init PDA), mint, authority (signer), system_program.
    • Effects: reserves next counter, computes deterministic token_id inputs, writes MintTicket.used=false.
  • mint_nft(name, symbol, uri)

    • Accounts: config, ticket (mut, close=authority), nft_origin (PDA), mint, metadata, master_edition, token_account (recipient ATA), authority (signer), payer, recipient, token_program, associated_token_program, metadata_program, system_program, rent.
    • Preconditions: lengths within limits; config not paused; ticket matches mint/authority and not used.
    • Effects: recomputes token_id; marks ticket used early; creates metadata and master edition; mints exactly 1; creates NftOrigin (idempotent, no overwrite) and stores original fields; emits NftMinted and OriginTracked.
  • transfer_to_zetachain(token_id[32], receiver[20], destination_chain u64, final_recipient string)

    • Accounts: config, nft_origin, mint, token_account (owner ATA), metadata, owner (signer), gateway_program (matches config and executable), gateway_pda (equals config and owned by gateway_program), token_program, system_program.
    • Effects: validates origin/mint/ATA invariants; burns exactly 1; updates origin (off-Solana); CPIs to Gateway call with ABI-encoded message; emits CrossChainTransferInitiated and NftBurned.
  • on_call(amount u64, sender[20], data Vec)

    • Accounts: pda=config, nft_origin (PDA), pda_ata (program’s ATA for mint), mint_account, metadata (PDA), master_edition (PDA), gateway_pda (equals config and owned by gateway_program), metadata_program, system_program, token_program, rent. Remaining: instructions sysvar.
    • Effects: authenticates caller by checking previous instruction’s program_id == gateway_program and pinned gateway_pda; creates metadata and master edition if missing; mints exactly 1 to program ATA; updates origin to on-Solana; emits NftMinted.
  • update_config(new_authority?, new_gateway_program?, new_gateway_pda?, pause?)

    • Accounts: config (mut), authority (signer).
    • Effects: validates nonzero and executability/ownership (if accounts provided in remaining_accounts); updates fields; emits ProgramConfigUpdated.

Cross-chain flow

  • Solana → ZetaChain
    1. Owner calls transfer_to_zetachain.
    2. Program burns NFT from owner ATA and sends a message via Gateway.call using pinned accounts.
    3. Origin marked off-Solana.
  • ZetaChain → Solana (return)
    1. Gateway CPIs into on_call.
    2. Program authenticates the caller, (lazily) creates metadata/master edition, mints 1 to program ATA, and marks origin on-Solana.

Security and invariants

  • Deterministic IDs: token_id = sha256(mint || slot_LE || reserved_id_LE) from reservation ticket.
  • Race resistance: ticket.used set early; idempotent origin creation; no overwrite.
  • Strict PDAs and ATAs: all critical accounts are derived or checked via seeds; ATAs derived and matched.
  • Gateway authentication: config pins gateway_program and exact gateway_pda; on_call verifies previous ix’s program_id and PDA ownership.
  • Asset correctness: decimals == 0; supply transitions 0→1 on mint, and 1→0 on burn; amounts verified == 1.
  • Input caps: name/symbol/uri lengths; final_recipient length <= 128.

Events

  • ProgramConfigured, ProgramConfigUpdated (include gateway_pda), NftMinted, OriginTracked, CrossChainTransferInitiated, NftBurned.

Client notes (TS example)

  • initialize requires both gatewayProgram and gatewayPda.
  • transfer reads gatewayProgram and gatewayPda from on-chain config (less user error).
  • status prints gatewayPda for quick verification.

Summary by CodeRabbit

  • New Features

    • Universal NFT on Solana: init, ticketed token reservation, deterministic minting, origin tracking, lifecycle & cross‑chain transfer flows, inbound handling, config updates, and rich events for monitoring.
    • Developer CLI & scripts: wallet-driven commands (init, reserve/mint, transfer, status, balance, origin) and end‑to‑end devnet/testnet automation for minting and cross‑chain flows.
  • Chores

    • Workspace, build/tooling configs, package scripts, TypeScript config, sample metadata, and ignore/format files added for development.

Copilot AI review requested due to automatic review settings August 19, 2025 12:30
@alperimo alperimo requested a review from a team as a code owner August 19, 2025 12:30
@coderabbitai
Copy link

coderabbitai bot commented Aug 19, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

📝 Walkthrough

Walkthrough

Adds a new Solana Anchor "universal-nft" program with full NFT lifecycle and cross‑chain transfer support, plus account types, instruction contexts, events, utilities, CPIs, a TypeScript CLI example, Anchor/Cargo workspace configs, migration and package files, and shell automation scripts for devnet/testnet flows.

Changes

Cohort / File(s) Summary
Workspace & top-level configs
Cargo.toml, contracts/nft/contracts/solana/Cargo.toml
Adds a Cargo workspace (resolver = "2"), registers workspace members (including the universal-nft program), centralizes workspace dependencies, and configures Rust release/profile settings.
Solana package & tooling
contracts/nft/contracts/solana/Anchor.toml, contracts/nft/contracts/solana/package.json, contracts/nft/contracts/solana/tsconfig.json, contracts/nft/contracts/solana/.gitignore, contracts/nft/contracts/solana/.prettierignore, contracts/nft/contracts/solana/migrations/deploy.ts
Adds Anchor project configuration (provider, programs, scripts), npm scripts and dev dependencies, TypeScript config, ignore files, and a migration scaffold.
Program crate manifests
contracts/nft/contracts/solana/programs/universal-nft/Cargo.toml, .../Xargo.toml
New crate manifest for universal-nft with features and dependencies (anchor-lang, anchor-spl, mpl-token-metadata) and Xargo target settings.
Program root & handlers
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs
Implements Anchor handlers: initialize, reserve_next_token_id, mint_nft, transfer_to_zetachain, on_call, update_config; includes CPIs to Metaplex and ZetaChain gateway, event emissions, PDA usage and comprehensive validations.
Instruction contexts
.../context/mod.rs, .../context/{initialize,reserve_next_token_id,mint_nft,transfer_to_zetachain,on_call,update_config}.rs
Adds Anchor Accounts structs for each instruction with PDA seeds, bumps, constraints, ownership and program validations.
On‑chain state types
.../state/mod.rs, .../state/{config,mint_ticket,nft_origin}.rs
Adds account types UniversalNftConfig, MintTicket, and NftOrigin with fields, LEN/SPACE constants, constructor and mutators.
Errors & events
.../error/mod.rs, .../event/mod.rs, .../event/{config,cross_chain,nft_lifecycle,origin}.rs
Introduces UniversalNftError enum and multiple event structs for config changes, cross‑chain lifecycle, NFT mint/burn, and origin tracking.
Utilities
.../util/mod.rs, .../util/{constants,metaplex_helpers,mint_helpers,cross_chain_helpers,gateway_helpers}.rs
Adds constants, Metaplex CPI helpers, SPL mint helper, cross‑chain encode/decode and address helpers, and gateway encoders (including unit tests for encoders).
Module re-exports
.../util/mod.rs, .../context/mod.rs, .../event/mod.rs, .../state/mod.rs
Registers and re-exports new submodules to expose the program API surface.
TypeScript CLI & samples
contracts/nft/contracts/example/SolanaUniversalNFT.ts, contracts/nft/assets/metadata.sample.json
Adds UniversalNftCLI TypeScript implementation (wallet loading, PDAs, initialize/update/mint/transfer/status/origin/balance) and a sample metadata JSON.
Shell automation scripts
contracts/nft/scripts/solana_devnet_nft.sh, contracts/nft/scripts/testnet_nft.sh
Adds end-to-end scripts for build/deploy/mint/transfer flows on devnet/testnet with environment defaults, parsing, retries and user-facing output.
Miscellaneous
contracts/nft/contracts/solana/programs/universal-nft/src/context/*.rs (new files)
Multiple context files and supporting utilities added across the program crate to enable the described flows.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant CLI as TS CLI
  participant Program as Universal NFT (Anchor)
  participant Meta as Metaplex Metadata
  participant SPL as SPL Token

  CLI->>Program: initialize(gateway_program, gateway_pda)
  Program-->>CLI: ProgramConfigured (event)

  CLI->>Program: reserve_next_token_id(mint)
  Program-->>CLI: MintTicket created

  CLI->>Program: mint_nft(name,symbol,uri)
  Note right of Program: validate ticket → derive token_id & origin PDA
  Program->>Meta: CreateMetadataAccountV3 (CPI)
  Program->>Meta: CreateMasterEditionV3 (CPI)
  Program->>SPL: MintTo amount=1 (CPI)
  Program-->>CLI: NftMinted, OriginTracked
Loading
sequenceDiagram
  autonumber
  participant Owner as Owner/CLI
  participant Program as Universal NFT (Anchor)
  participant SPL as SPL Token
  participant Gateway as ZetaChain Gateway

  Owner->>Program: transfer_to_zetachain(token_id, zc_contract, dst_zrc20, recipient, deposit)
  Note right of Program: validate origin, owner ATA, metadata
  Program->>SPL: Burn NFT (CPI)
  Program->>Gateway: deposit_and_call (CPI) with encoded payload
  Program-->>Owner: CrossChainTransferInitiated, NftBurned
Loading
sequenceDiagram
  autonumber
  participant Gateway as ZetaChain Gateway
  participant Program as Universal NFT (Anchor)
  participant Meta as Metaplex
  participant SPL as SPL Token

  Gateway->>Program: on_call(amount,sender,data)
  Note right of Program: decode payload, create/load origin, update state
  Program->>Meta: CreateMetadata/CreateMasterEdition (CPI if needed)
  Program->>SPL: MintTo program ATA (CPI)
  Program-->>Gateway: NftMinted / success
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 61fca24 and 2d75c2e.

📒 Files selected for processing (2)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@github-actions github-actions bot added the feat label Aug 19, 2025
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces a comprehensive Universal NFT program on Solana using Anchor that enables cross-chain NFT transfers via ZetaChain Gateway integration. The implementation provides deterministic token ID generation, secure cross-chain operations, and complete metadata management.

Key changes:

  • Implements a complete Universal NFT program with secure minting, burning, and cross-chain transfer capabilities
  • Provides deterministic token ID generation using a two-step reserve-and-mint flow to prevent race conditions
  • Integrates with ZetaChain Gateway for cross-chain operations with strict authentication and validation

Reviewed Changes

Copilot reviewed 35 out of 39 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lib.rs Main program implementation with core instructions (initialize, mint, transfer, on_call)
state/ Account structures for config, NFT origin tracking, mint tickets, and metadata
util/ Helper modules for constants, Metaplex integration, cross-chain data handling, and mint operations
context/ Account validation contexts for each instruction
event/ Event definitions for lifecycle, cross-chain, config, and origin tracking
error/ Comprehensive error definitions
example/SolanaUniversalNFT.ts TypeScript CLI client for interacting with the program

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

♻️ Duplicate comments (1)
Cargo.toml (1)

1-5: Duplication with the sub-workspace (contracts/nft/contracts/solana/Cargo.toml).

There are two workspaces including the same program. See my earlier comment in contracts/nft/contracts/solana/Cargo.toml for consolidation options.

🧹 Nitpick comments (36)
contracts/nft/contracts/solana/programs/universal-nft/Xargo.toml (1)

1-2: Consider enabling panic_immediate_abort for Solana BPF builds.

On Solana, aborting immediately on panic reduces binary size and avoids formatting code in panic paths. If you intend to keep Xargo, prefer enabling this std feature for the BPF target.

Apply this diff:

 [target.bpfel-unknown-unknown.dependencies.std]
-features = []
+features = ["panic_immediate_abort"]

If you’re on the modern SBF toolchain where Xargo is no longer used in your build path, consider removing this file to avoid confusion.

contracts/nft/contracts/solana/.gitignore (1)

1-7: .gitignore looks fine for Anchor/Solana; minor Yarn v3 caveat.

If you adopt Yarn Berry in this subproject, ignoring the entire .yarn directory will also ignore required files (releases/plugins). Otherwise, this is fine.

If you plan to use Yarn Berry, prefer:

-.yarn
+.yarn/*
+!.yarn/releases
+!.yarn/plugins
+!.yarn/sdks
+!.yarn/versions
contracts/nft/contracts/solana/tsconfig.json (1)

2-9: Add Node types and modern lib for Anchor TS tests; set moduleResolution and skipLibCheck.

Mocha/Anchor tests commonly reference Node globals (Buffer, process). Also, using a newer lib target avoids missing typings for modern JS. Adding skipLibCheck speeds up CI.

Apply this diff:

 {
   "compilerOptions": {
-    "types": ["mocha", "chai"],
+    "types": ["mocha", "chai", "node"],
-    "typeRoots": ["./node_modules/@types"],
-    "lib": ["es2015"],
-    "module": "commonjs",
-    "target": "es6",
-    "esModuleInterop": true
+    "typeRoots": ["./node_modules/@types"],
+    "lib": ["es2020"],
+    "module": "commonjs",
+    "moduleResolution": "node",
+    "target": "es2020",
+    "esModuleInterop": true,
+    "skipLibCheck": true
   }
 }
contracts/nft/contracts/solana/Cargo.toml (2)

7-10: Consider adding opt-level="s" for Solana BPF release builds.

For Solana programs, size and inlining matter. The common release profile is lto="fat", codegen-units=1, and opt-level="s" to keep the program within size limits while optimizing appropriately.

Apply this diff:

 [profile.release]
 overflow-checks = true
 lto = "fat"
+opt-level = "s"
 codegen-units = 1

1-6: Avoid maintaining nested Cargo workspaces unless there’s a strong reason.

This repo defines a root workspace (Cargo.toml) and another workspace here that also includes the same program via programs/*. That can lead to drift in profiles/deps depending on where cargo is invoked (repo root vs this subdir). Prefer a single workspace root or document the dual-usage and keep settings in sync.

  • Option A (simplest): Remove this sub-workspace and rely on the root workspace.
  • Option B: Keep this sub-workspace but do not list the same packages in the root workspace members; treat this as an independent workspace used only when building from this subdir.
  • Option C: Keep both, but centralize release profiles and dependency pins in one place and reference them consistently to avoid divergence.
contracts/nft/contracts/solana/migrations/deploy.ts (2)

7-12: Avoid mixing ESM imports with CommonJS exports in TS migration scripts.

Anchor’s migration runner historically expects CommonJS. To prevent module interop pitfalls, keep both import and export in CJS style.

Apply this diff:

-import * as anchor from "@coral-xyz/anchor";
+// Use CJS style to match module.exports below and Anchor's migration loader
+const anchor = require("@coral-xyz/anchor");
 
-module.exports = async function (provider: anchor.AnchorProvider) {
+module.exports = async function (provider: any) {
   // Configure client to use the provider.
   anchor.setProvider(provider);
 
   // Add your deploy script here.
 };

9-11: Add a minimal sanity log to help operators confirm environment.

Tiny, non-invasive logging helps validate the migration is running against the intended cluster/wallet.

Apply this diff:

   // Configure client to use the provider.
   anchor.setProvider(provider);
 
   // Add your deploy script here.
+  const endpoint = provider.connection.rpcEndpoint;
+  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+  const walletPubkey = provider.wallet?.publicKey?.toBase58?.() ?? "<unknown>";
+  // Useful trace for CI and operators
+  console.log(`[universal-nft] Migration initialized. RPC: ${endpoint}, wallet: ${walletPubkey}`);
contracts/nft/contracts/solana/programs/universal-nft/src/event/origin.rs (1)

13-17: “created_at” vs “timestamp” may be redundant; clarify or rename.

Both fields are timestamps; consider either dropping one or renaming “timestamp” to “emitted_at” to avoid confusion between origin creation time and event emission time.

contracts/nft/contracts/solana/programs/universal-nft/src/error/mod.rs (3)

3-5: Stabilize error code ordering going forward.

Anchor assigns error codes by enum order. After initial release, append new variants at the end only to keep codes stable across upgrades.

Apply this diff to document it in-code:

-#[error_code]
+/// NOTE: Maintain variant order; append new variants at the end to preserve stable error codes.
+#[error_code]
 pub enum UniversalNftError {

107-115: Disambiguate duplicate “Invalid metadata account” messages.

Both InvalidMetadata and InvalidMetadataAccount have the same message, which is confusing during debugging/monitoring. Clarify the intent of each.

Apply this diff:

-    #[msg("Invalid metadata account")]
+    #[msg("Invalid metadata PDA (Metaplex)")]
     InvalidMetadata,
@@
-    #[msg("Invalid metadata account")]
+    #[msg("Invalid metadata account (wrong owner or not PDA)")]
     InvalidMetadataAccount,

Also applies to: 119-124


116-118: Categorization nit: CrossChainTransferFailed under METAPLEX ERRORS is misleading.

Consider moving this variant to the CROSS-CHAIN ERRORS section for consistency. If you want to avoid reordering now, add a comment and relocate later before the first public release.

contracts/nft/contracts/solana/Anchor.toml (1)

17-22: Consider adding a test validator config to clone required external programs

Your program CPIs into Metaplex Token Metadata (and possibly the Gateway). Local tests often need those programs’ code cloned into the local test validator, or they’ll fail at CPI time.

You can extend Anchor.toml like this (choose the right Gateway program address your tests use):

 [provider]
 cluster = "devnet"
 wallet = "~/.config/solana/id.json"

 [scripts]
 test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
+
+[test.validator]
+# Option: clone from devnet so the local validator has these programs available during `anchor test`
+url = "https://api.devnet.solana.com"
+clone = [
+  # Metaplex Token Metadata
+  { address = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" },
+  # SPL Associated Token (if needed)
+  { address = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" },
+  # ZetaChain Gateway program (replace with the address you pin in config)
+  # { address = "<GATEWAY_PROGRAM_ADDRESS>" },
+]

If your test suite spins up its own validator or runs on devnet, this may be unnecessary; otherwise, it saves a lot of CPI headaches.

contracts/nft/contracts/solana/programs/universal-nft/src/event/mod.rs (1)

1-4: Nit: add a module-level doc to aid discoverability

A short comment about what each submodule covers helps future contributors.

Apply:

+//! Event definitions for the Universal NFT program, organized by concern.
+//! - nft_lifecycle: mint/burn lifecycle events
+//! - cross_chain: cross-chain transfer events
+//! - config: program configuration events
+//! - origin: NFT origin tracking events
 pub mod nft_lifecycle;
 pub mod cross_chain;
 pub mod config;
 pub mod origin;
contracts/nft/contracts/solana/programs/universal-nft/src/context/update_config.rs (1)

5-16: Add brief context docs; consider pause semantics (optional)

  • Add a short docstring to explain what fields can be updated and who may call it.
  • If your design requires that updates remain possible while is_paused, ensure the instruction handler doesn’t block updates when paused (context is fine; this is about handler logic).

You can add docs:

 #[derive(Accounts)]
 pub struct UpdateConfig<'info> {
+    /// Program config PDA. Only the `authority` can update fields.
+    /// Note: Updates may be allowed regardless of `is_paused` (enforced in handler).
     #[account(
         mut,
         seeds = [UNIVERSAL_NFT_CONFIG_SEED],
         bump = config.bump,
         has_one = authority
     )]
     pub config: Account<'info, UniversalNftConfig>,
     
     pub authority: Signer<'info>,
 }
contracts/nft/contracts/solana/programs/universal-nft/src/event/nft_lifecycle.rs (1)

20-32: Optional: include a hex-encoded token_id for human-readable logs

Indexers can convert bytes to hex, but adding a precomputed hex string can simplify observability and debugging, especially across chains.

If you want this, consider emitting both fields:

 #[event]
 pub struct NftMinted {
   pub mint: Pubkey,
   pub token_id: [u8; 32],
+  /// Hex-encoded token ID (for human-readable logs)
+  pub token_id_hex: String,
   pub owner: Pubkey,
   pub uri: String,
   pub timestamp: i64,
 }

 #[event]
 pub struct NftBurned {
   pub mint: Pubkey,
   pub token_id: [u8; 32],
+  /// Hex-encoded token ID (for human-readable logs)
+  pub token_id_hex: String,
   pub owner: Pubkey,
   pub timestamp: i64,
   pub reason: String,
 }

This is optional—only if you value log readability over a few extra bytes.

contracts/nft/contracts/solana/programs/universal-nft/Cargo.toml (1)

20-23: Pinning Anchor deps is fine; consider workspace unification and metadata (optional)

  • If you already use [workspace.dependencies], consider referencing them here to avoid drift across crates.
  • Add license and repository for completeness if publishing/artifact tracking matters.

Example:

 [package]
 name = "universal-nft"
 version = "0.1.0"
 description = "Created with Anchor"
 edition = "2021"
+license = "Apache-2.0"
+repository = "https://github.com/<org>/<repo>"

 [dependencies]
-anchor-lang = "0.31.1"
-anchor-spl = "0.31.1"
+anchor-lang = { workspace = true }
+anchor-spl  = { workspace = true }
 hex = "0.4"
contracts/nft/contracts/solana/programs/universal-nft/src/util/mint_helpers.rs (2)

7-9: Document preconditions and invariants for safe NFT minting

Clarify what must hold true for callers (mint authority, mint/account match, decimals==0). This prevents subtle misuse across contexts.

-/// Mint an NFT token to a recipient's token account
-/// This is a common operation used in mint_nft, on_call, and on_revert
+/// Mint an NFT token to a recipient's token account.
+/// Common operation used in mint_nft, on_call, and on_revert.
+/// Preconditions:
+/// - token_account.mint == mint_account.key()
+/// - mint has decimals == 0 (enforced by caller/context)
+/// - `authority` is the mint's mint_authority (or a PDA with `authority_signer_seeds`)
+/// - amount is fixed to 1 by design (NFT semantics)

15-35: Reduce duplication and add a defensive mint/mint-account check

  • Build MintTo accounts once and branch only on signer usage.
  • Add a debug assertion to catch mismatched token account mint during development.
 ) -> Result<()> {
-    let mint_to_ctx = if let Some(signer_seeds) = authority_signer_seeds {
-        CpiContext::new_with_signer(
-            token_program.to_account_info(),
-            MintTo {
-                mint: mint_account.to_account_info(),
-                to: token_account.to_account_info(),
-                authority: authority.clone(),
-            },
-            signer_seeds,
-        )
-    } else {
-        CpiContext::new(
-            token_program.to_account_info(),
-            MintTo {
-                mint: mint_account.to_account_info(),
-                to: token_account.to_account_info(),
-                authority: authority.clone(),
-            },
-        )
-    };
+    debug_assert_eq!(
+        token_account.mint,
+        mint_account.key(),
+        "token_account.mint does not match provided mint"
+    );
+    let accounts = MintTo {
+        mint: mint_account.to_account_info(),
+        to: token_account.to_account_info(),
+        authority: authority.clone(),
+    };
+    let mint_to_ctx = match authority_signer_seeds {
+        Some(seeds) => CpiContext::new_with_signer(
+            token_program.to_account_info(),
+            accounts,
+            seeds,
+        ),
+        None => CpiContext::new(token_program.to_account_info(), accounts),
+    };
contracts/nft/contracts/solana/programs/universal-nft/src/event/config.rs (1)

3-34: LGTM: Clear and appropriately scoped config events

The event shapes look good for initialize/update flows and include the essential provenance fields. If you intend Option in ProgramConfigUpdated to signal “unchanged” vs “changed to None”, consider documenting that semantic in the context modules for log consumers.

contracts/nft/contracts/solana/programs/universal-nft/src/context/reserve_next_token_id.rs (1)

25-26: Optional: add a defensive constraint on mint

Since mint is only used as a seed, this is fine. If you want extra safety, consider asserting it’s a system-owned account (i.e., not already initialized by an unexpected program) when reserving:

  • constraint = mint.owner == system_program.key()

Not strictly required for correctness.

-    /// CHECK: only used as seed
-    pub mint: UncheckedAccount<'info>,
+    /// CHECK: only used as seed
+    #[account(constraint = mint.owner == system_program.key())]
+    pub mint: UncheckedAccount<'info>,
contracts/nft/contracts/solana/programs/universal-nft/src/context/initialize.rs (1)

19-21: Optional: move gateway executability check into account constraints

You already validate executability in the handler. You can push it into account constraints for earlier failure.

-    /// CHECK: ZetaChain gateway program (must be executable)
-    pub gateway_program: UncheckedAccount<'info>,
+    /// CHECK: ZetaChain gateway program (must be executable)
+    #[account(constraint = gateway_program.executable @ crate::error::UniversalNftError::InvalidGatewayProgram)]
+    pub gateway_program: UncheckedAccount<'info>,
contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (3)

80-104: Add encode_nft_data_with_recipient to complement the decode variant

You expose a decode variant with a recipient but only provide an encode variant without it. Adding a symmetric encoder reduces duplication at call sites and avoids error-prone manual concatenation.

Apply this diff to add the missing encoder:

@@
 pub fn encode_nft_data(
@@
     data
 }
 
+/// Encode NFT data (with recipient) for cross-chain message
+/// Format: token_id(32) + source_chain(8) + recipient(32) + metadata_uri + name + symbol
+pub fn encode_nft_data_with_recipient(
+    token_id: [u8; 32],
+    source_chain: u64,
+    recipient: [u8; 32],
+    metadata_uri: &str,
+    name: &str,
+    symbol: &str,
+) -> Vec<u8> {
+    let mut data = Vec::new();
+    // Token ID (32 bytes)
+    data.extend_from_slice(&token_id);
+    // Source chain (8 bytes, big-endian)
+    data.extend_from_slice(&source_chain.to_be_bytes());
+    // Recipient (32 bytes)
+    data.extend_from_slice(&recipient);
+    // Strings (each length-prefixed with 4-byte big-endian length)
+    encode_length_prefixed_string(&mut data, metadata_uri);
+    encode_length_prefixed_string(&mut data, name);
+    encode_length_prefixed_string(&mut data, symbol);
+    data
+}

145-151: Normalize 0x/0X prefix and trim whitespace in hex parsing

Currently only lowercase "0x" is recognized, and leading/trailing whitespace will cause a failure. Minor hardening below.

-pub fn hex_string_to_address(address_str: &str) -> Result<[u8; 20]> {
-    let hex_str = if address_str.starts_with("0x") {
-        &address_str[2..]
-    } else {
-        address_str
-    };
+pub fn hex_string_to_address(address_str: &str) -> Result<[u8; 20]> {
+    let addr = address_str.trim();
+    let hex_str = if addr.starts_with("0x") || addr.starts_with("0X") {
+        &addr[2..]
+    } else {
+        addr
+    };

105-129: Consider enforcing length caps for decoded strings

Program-wide limits are documented (capped name/symbol/uri). Enforcing them here would fail fast on malformed/crafted payloads and reduce allocation risk.

If you have constants (e.g., MAX_NAME_LEN, MAX_SYMBOL_LEN, MAX_URI_LEN) in util/constants, check lengths after extraction and return InvalidDataFormat on overflow. I can draft a patch once constants are confirmed.

contracts/nft/contracts/solana/programs/universal-nft/src/event/cross_chain.rs (1)

62-74: Add token and chain context to CrossChainTransferReverted

The revert event currently lacks token_id and chain IDs, making it hard to correlate with the original transfer. Add parity fields for observability.

 #[event]
 pub struct CrossChainTransferReverted {
+    /// Universal token ID being reverted
+    pub token_id: [u8; 32],
+    /// Source chain ID
+    pub source_chain: u64,
+    /// Destination chain ID
+    pub destination_chain: u64,
     /// Original sender who initiated the transfer
     pub sender: Pubkey,
     /// Amount of SOL refunded (if any)
     pub amount: u64,
     /// Reason for the revert
     pub revert_reason: String,
     /// Timestamp of revert
     pub timestamp: i64,
     /// Whether an NFT was restored during revert
     pub nft_restored: bool,
 }

This will require updating emit sites accordingly.

contracts/nft/contracts/solana/programs/universal-nft/src/context/mint_nft.rs (3)

47-57: Add owner constraints for Metadata PDA

Even though the PDA derivation is constrained to TOKEN_METADATA_PROGRAM_ID via seeds::program, adding an explicit owner check is a simple hardening step.

     #[account(
         mut,
         seeds = [
             b"metadata",
             TOKEN_METADATA_PROGRAM_ID.as_ref(),
             mint.key().as_ref(),
         ],
         bump,
+        owner = TOKEN_METADATA_PROGRAM_ID,
         seeds::program = TOKEN_METADATA_PROGRAM_ID
     )]
     pub metadata: UncheckedAccount<'info>,

61-72: Add owner constraints for Master Edition PDA

Same rationale as metadata.

     #[account(
         mut,
         seeds = [
             b"metadata",
             TOKEN_METADATA_PROGRAM_ID.as_ref(),
             mint.key().as_ref(),
             b"edition",
         ],
         bump,
+        owner = TOKEN_METADATA_PROGRAM_ID,
         seeds::program = TOKEN_METADATA_PROGRAM_ID
     )]
     pub master_edition: UncheckedAccount<'info>,

74-82: Use init_if_needed for ATA to allow idempotent mints to the same recipient

If the recipient’s ATA already exists (e.g., retries or pre-existing account), init will fail. init_if_needed improves UX without weakening constraints.

     #[account(
-        init,
+        init_if_needed,
         payer = payer,
         associated_token::mint = mint,
         associated_token::authority = recipient,
     )]
     pub token_account: Account<'info, TokenAccount>,
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)

18-28: Remove dead helpers or wire them where needed

toU64LE and sha256 are currently unused, increasing noise. Either remove them or hook them up where intended.

-function toU64LE(n: bigint): Buffer {
-  const b = Buffer.alloc(8);
-  b.writeBigUInt64LE(n);
-  return b;
-}
-
-function sha256(...parts: Buffer[]): Buffer {
-  const h = crypto.createHash("sha256");
-  for (const p of parts) h.update(p);
-  return h.digest();
-}
+// Helpers toU64LE and sha256 removed (unused). Reintroduce when needed.
contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs (2)

61-68: Constrain metadata_program with explicit address match error from your error enum

Currently this uses Anchor’s generic ConstraintAddress error. For consistency and clearer diagnostics, return your domain error (e.g., InvalidMetadataProgram) or reuse InvalidGatewayProgram analog for metadata.

-    #[account(constraint = metadata_program.key() == TOKEN_METADATA_PROGRAM_ID @ anchor_lang::error::ErrorCode::ConstraintAddress)]
+    #[account(
+        constraint = metadata_program.key() == TOKEN_METADATA_PROGRAM_ID
+            @ crate::error::UniversalNftError::InvalidMetadata
+    )]

20-24: Consider allowing ATA auto-creation or documenting requirement

on_call assumes pda_ata exists and only validates derivation. If the gateway invokes this CPI without an existing ATA, mint will fail. Either:

  • add optional associated token program account and create ATA when missing; or
  • document that the caller must pre-create pda_ata.

I can provide a minimal ATA-create branch guarded by data_is_empty() and presence of associated token program in remaining_accounts.

Also applies to: 43-57

contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (2)

469-497: on_call funds metadata/edition creation from the config PDA; ensure it’s funded or accept an external payer

You use the config PDA as payer (with signer seeds) for metadata and master edition CPIs, but no guarantee exists that this PDA has enough lamports. Consider:

  • accepting a payer account in OnCall context; or
  • ensuring the gateway pre-funds the config PDA; or
  • failing early with a clear error if balance is insufficient.

I can add a balance check with Rent::minimum_balance and a dedicated error to surface this condition.

Also applies to: 558-566, 567-581


21-21: Remove dead code comment

Leftover commented import // use std::io::Seek; can be removed.

-// use std::io::Seek;
contracts/nft/contracts/solana/programs/universal-nft/src/context/transfer_to_zetachain.rs (2)

41-52: Redundant constraint on metadata if seeds verify Metaplex PDA

You both seed-derive metadata via TOKEN_METADATA_PROGRAM_ID seeds and assert equality to nft_origin.original_metadata. Either is sufficient; keeping both is fine but adds CU and complexity. Consider keeping only the PDA derivation, which already guarantees the correct address for this mint.

-        constraint = metadata.key() == nft_origin.original_metadata @ crate::error::UniversalNftError::InvalidMetadata,

57-64: Gateway program validation good; ensure executable is enforced at Initialize time only

You already enforce executable at initialize; duplicating executable check here is harmless. If CU-sensitive, you can rely on the earlier guarantee and keep only key equality.

Also applies to: 65-71

contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs (1)

109-114: Hardcoded ZetaChain gateway IDs: consider environment gating

You’ve embedded devnet and mainnet IDs as comments with a devnet default constant. Suggest exposing this via Initialize (which you do) and keep constants only for documentation, or guard with cfg(feature = "...") so binaries don’t accidentally reference the wrong cluster.

I can refactor to remove this constant and rely solely on program config set in initialize/update_config, reducing risk of mismatched IDs.

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6a83c69 and bcfa67e.

⛔ Files ignored due to path filters (4)
  • contracts/nft/contracts/solana/Cargo.lock is excluded by !**/*.lock
  • contracts/nft/contracts/solana/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
  • contracts/nft/package-lock.json is excluded by !**/package-lock.json
  • contracts/nft/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (35)
  • Cargo.toml (1 hunks)
  • contracts/nft/contracts/example/SolanaUniversalNFT.ts (1 hunks)
  • contracts/nft/contracts/solana/.gitignore (1 hunks)
  • contracts/nft/contracts/solana/.prettierignore (1 hunks)
  • contracts/nft/contracts/solana/Anchor.toml (1 hunks)
  • contracts/nft/contracts/solana/Cargo.toml (1 hunks)
  • contracts/nft/contracts/solana/migrations/deploy.ts (1 hunks)
  • contracts/nft/contracts/solana/package.json (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/Cargo.toml (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/Xargo.toml (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/initialize.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/mint_nft.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/mod.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/reserve_next_token_id.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/transfer_to_zetachain.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/update_config.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/error/mod.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/event/config.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/event/cross_chain.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/event/mod.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/event/nft_lifecycle.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/event/origin.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/config.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/metadata.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/mint_ticket.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/mod.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/metaplex_helpers.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/mint_helpers.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/mod.rs (1 hunks)
  • contracts/nft/contracts/solana/tsconfig.json (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (8)
contracts/nft/contracts/solana/programs/universal-nft/src/context/mod.rs (2)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)
  • initialize (94-108)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (6)
  • initialize (29-67)
  • mint_nft (103-241)
  • transfer_to_zetachain (245-397)
  • on_call (399-592)
  • update_config (595-656)
  • reserve_next_token_id (71-99)
contracts/nft/contracts/solana/programs/universal-nft/src/event/mod.rs (1)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)
  • origin (217-227)
contracts/nft/contracts/solana/programs/universal-nft/src/context/reserve_next_token_id.rs (1)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)
  • mint (110-161)
contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (1)
contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs (1)
  • new (31-49)
contracts/nft/contracts/solana/programs/universal-nft/src/util/metaplex_helpers.rs (1)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)
  • mint (110-161)
contracts/nft/contracts/solana/programs/universal-nft/src/context/mint_nft.rs (1)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)
  • mint (110-161)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (5)
contracts/nft/contracts/solana/programs/universal-nft/src/util/metaplex_helpers.rs (2)
  • create_metadata_account (11-92)
  • create_master_edition_account (95-159)
contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (1)
  • decode_nft_data_with_recipient (44-78)
contracts/nft/contracts/solana/programs/universal-nft/src/util/mint_helpers.rs (1)
  • mint_nft_to_recipient (9-39)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (2)
  • initialize (94-108)
  • mint (110-161)
contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs (1)
  • new (31-49)
contracts/nft/contracts/solana/programs/universal-nft/src/context/transfer_to_zetachain.rs (1)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)
  • mint (110-161)
🔇 Additional comments (26)
contracts/nft/contracts/solana/.prettierignore (1)

1-7: Prettier ignore set is appropriate for this workspace.

Covers common build and local artifacts for Anchor/Solana projects.

contracts/nft/contracts/solana/programs/universal-nft/src/event/origin.rs (1)

5-18: URI length is already constrained before emission
The uri parameter passed into OriginTracked comes from the user‐supplied uri in mint_nft, which is gated by:

// src/lib.rs:109–113
require!(name.len()   <= MAX_NAME_LENGTH,   UniversalNftError::NameTooLong);
require!(symbol.len() <= MAX_SYMBOL_LENGTH, UniversalNftError::SymbolTooLong);
require!(uri.len()    <= MAX_URI_LENGTH,    UniversalNftError::UriTooLong);

Because original_uri (and the cross‐chain metadata_uri) both derive directly from this validated uri, no additional length checks are needed before emitting the event.

Cargo.toml (2)

7-9: Centralizing Anchor deps looks good.

Pinning anchor-lang and anchor-spl at the workspace level reduces version skew across crates.


7-9: Ensure matching Solana SDK 2.1.0 and anchor-cli 0.31.1

To avoid build failures with Anchor 0.31.1, confirm both your local and CI toolchains use the following versions:

• Solana SDK: v2.1.0 (Agave-renamed binaries)
• anchor-cli: v0.31.1

Recommended installation via AVM:

avm install 0.31.1
avm use 0.31.1

Update your CI workflows and developer setup docs to enforce these versions.

contracts/nft/contracts/solana/Anchor.toml (1)

8-12: Program ID alignment verified
The declare_id! in contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs matches both the localnet and devnet entries in contracts/nft/contracts/solana/Anchor.toml. No further changes needed.

contracts/nft/contracts/solana/programs/universal-nft/src/event/mod.rs (1)

1-9: Module organization and re-exports look good

Clear separation by concern and flattened re-exports for ergonomic use in lib.rs. No issues spotted.

contracts/nft/contracts/solana/programs/universal-nft/src/context/update_config.rs (1)

7-16: Account constraints are correct and minimal

  • PDA is derived from a constant seed with bump = config.bump.
  • has_one = authority ties the config’s authority to the provided signer.
  • No unnecessary accounts pulled in.

Assuming UniversalNftConfig contains an authority: Pubkey and bump: u8, this is good.

contracts/nft/contracts/solana/programs/universal-nft/src/event/nft_lifecycle.rs (1)

5-18: NftMinted event shape is clear and useful

Fields provide enough context for off-chain indexers. Using [u8; 32] for token_id keeps it compact.

contracts/nft/contracts/solana/programs/universal-nft/Cargo.toml (1)

11-18: Feature flags are sensible for Anchor workflows

The set (cpi, no-entrypoint, no-idl, etc.) aligns with common patterns. No issues spotted.

contracts/nft/contracts/solana/programs/universal-nft/src/state/mod.rs (1)

1-9: Public re-exports are consistent and concise

Module layout and re-exports are clean, forming a stable public API surface for state types.

contracts/nft/contracts/solana/programs/universal-nft/src/state/config.rs (1)

27-29: UNIVERSAL_NFT_CONFIG_SPACE matches struct size and is properly documented

Verified that in
contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs
the comment and value of UNIVERSAL_NFT_CONFIG_SPACE (8 + 32 + 32 + 32 + 8 + 8 + 1 + 8 + 1 = 130) exactly correspond to the fields of UniversalNftConfig (122 bytes) plus the 8-byte discriminator. No further action needed.

contracts/nft/contracts/solana/programs/universal-nft/src/context/reserve_next_token_id.rs (2)

7-14: Config PDA constraints look solid

Seeded PDA with has_one authority and explicit bump usage are correct and align with the program’s deterministic config design.


16-23: Ensure the MintTicket is closed post-mint to refund rent

Ticket init looks correct here. Given the lib.rs comment that the ticket will be “auto-closed to authority via the close attribute,” please verify that MintNft context declares:

  • #[account(mut, close = authority)] for ticket

Without the close attribute in MintNft, rent will remain locked.

Would you like me to propose the exact MintNft context attribute changes?

contracts/nft/contracts/solana/programs/universal-nft/src/util/mod.rs (1)

1-9: LGTM: clean module surface and re-exports

The module plumbing is clear and promotes a unified util surface.

contracts/nft/contracts/solana/programs/universal-nft/src/context/initialize.rs (2)

24-27: Good: enforce gateway_pda ownership by the provided program

This prevents spoofing the gateway PDA and aligns with handler-level checks.


8-14: UNIVERSAL_NFT_CONFIG_SPACE already includes the 8-byte discriminator
I’ve confirmed in util/constants.rs (line 26) that

pub const UNIVERSAL_NFT_CONFIG_SPACE: usize = 8 + 32 + 32 + 32 + 8 + 8 + 1 + 8 + 1;

includes the initial 8‐byte discriminator. No changes needed here.

contracts/nft/contracts/solana/programs/universal-nft/src/context/mod.rs (1)

1-13: LGTM: context modules and re-exports are consistent

Clear split of contexts and public re-export surface. No issues.

contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs (2)

31-49: Constructor looks correct

Initial values (updated_at = created_at, is_on_solana = true) and bump storage are appropriate.


51-62: LGTM: state transition helpers are minimal and clear

The mark_transferred_off_solana and mark_to_solana helpers keep invariants simple and explicit.

contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (1)

10-39: Bounds-checked parsing looks solid

The minimal-length guard and subsequent string extraction with bounds checks are correct and avoid panics on malformed data.

contracts/nft/contracts/solana/programs/universal-nft/src/event/cross_chain.rs (1)

4-24: Event definitions read well

Field types and layout are appropriate for logging transfer initiation with sufficient context (ids, sender, recipient, gas_limit, nonce).

contracts/nft/contracts/solana/programs/universal-nft/src/state/metadata.rs (1)

3-20: LGTM: Metaplex-compatible metadata structs

Types mirror the common Metaplex shapes and derive the right Anchor traits for (de)serialization.

contracts/nft/contracts/solana/programs/universal-nft/src/context/mint_nft.rs (1)

30-35: Handler must strictly verify nft_origin PDA derivation

You’ve noted this as CHECK. Ensure the handler recomputes the token_id from ticket data, derives the expected PDA, and rejects mismatches before any state changes.

If helpful, I can generate a patch for the handler to enforce: derive -> compare -> initialize -> write.

contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)

94-108: Initialize: verify gatewayPda matches program expectation

The on-chain program pins both gateway_program and gateway_pda. Consider validating gatewayPda client-side early (format/public key) and surfacing a clearer error if mismatch occurs.

I can add a preflight that fetches config (if exists) and warns on mismatch before submitting the tx.

contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1)

332-370: Verify ZetaChain Gateway instruction format and IDL presence
I couldn’t find an Anchor IDL or JSON schema for the ZetaChain gateway in the repository. Before shipping, please:

  • Confirm whether the gateway program is Anchor-based and locate its IDL (e.g. in @zetachain/protocol-contracts-solana).
  • If it is, add the IDL as a dependency and derive the 8-byte “call” discriminator from it instead of hard-coding sha256("global:call")[..8].
  • Ensure the accounts: Vec<AccountMeta> you supply (payer, gateway PDA, signer, system program, etc.) matches exactly what the gateway’s call expects—many gateway CPIs also require an explicit payer or additional accounts.
  • If the gateway is not Anchor-based, update the instruction data packing to match its documented wire format rather than Anchor’s Borsh-discriminator layout.
contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs (1)

83-94: Ensure DEFAULT_ flags align with your Metaplex CPI usage*

You define DEFAULT_PRIMARY_SALE_HAPPENED and DEFAULT_IS_MUTABLE but only use DEFAULT_SELLER_FEE_BASIS_POINTS. When switching to mpl-token-metadata builders, consider whether you want to set is_mutable=true and whether to expose primary_sale_happened logic (usually handled by token-metadata program flows).

Do you plan to toggle primary_sale_happened at any point? If so, we can wire that into the metadata update flow.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
contracts/nft/contracts/solana/programs/universal-nft/src/state/mod.rs (2)

5-7: Replace wildcard re-exports with explicit items (+ optional prelude)

Avoid pub use ...::* to prevent unintentionally exposing helpers/constants and to keep a stable, intentional API surface. Re-export only the account types consumers need and optionally provide a prelude for ergonomic imports.

Apply this diff:

-pub use config::*;
-pub use nft_origin::*;
-pub use mint_ticket::*;
+pub use self::config::UniversalNftConfig;
+pub use self::nft_origin::NftOrigin;
+pub use self::mint_ticket::MintTicket;
+
+/// Convenient import group for consumers:
+/// use crate::state::prelude::*;
+pub mod prelude {
+    pub use super::{MintTicket, NftOrigin, UniversalNftConfig};
+}

If more items from these modules are intended for external use, we can explicitly list them here as needed rather than glob-exporting.


1-3: Privatize submodules and expose a stable API

No direct uses of state::config::, state::nft_origin:: or state::mint_ticket:: were found (ran rg -nP --type=rust -C2 '\bstate::(config|nft_origin|mint_ticket)::'). You can safely hide these modules and rely on existing re-exports.

• File:
contracts/nft/contracts/solana/programs/universal-nft/src/state/mod.rs

• Diff to apply:

+//! State module for the Universal NFT program.
//! Submodules are kept private; public items are re-exported below.
-pub mod config;
-pub mod nft_origin;
-pub mod mint_ticket;
+mod config;
+mod nft_origin;
+mod mint_ticket;

This optional refactor minimizes your public surface and reduces API-churn risk. Let me know if you need any help updating imports or re-exports.

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between bcfa67e and 14ab786.

📒 Files selected for processing (1)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/mod.rs (1 hunks)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
contracts/nft/contracts/solana/programs/universal-nft/src/util/metaplex_helpers.rs (2)

50-74: Fix CPI account order: missing duplicate authority and wrong ordering for CreateMetadataAccountV3

The accounts passed to invoke/invoke_signed must match the builder’s metas (including duplicates for the same pubkey). Current slices omit the second authority (update_authority) and shift system/rent into the wrong positions, which will cause the CPI to fail at runtime.

Align the AccountInfo slices with the builder order: [metadata, mint, mint_authority, payer, update_authority, system_program, rent].

Apply this diff:

@@
-    match authority_signer_seeds {
-        Some(seeds) => anchor_lang::solana_program::program::invoke_signed(
-            &ix,
-            &[
-                metadata.clone(),
-                mint.clone(),
-                authority.clone(),
-                payer.clone(),
-                system_program.clone(),
-                rent.clone(),
-            ],
-            seeds,
-        ).map_err(|_| UniversalNftError::MetadataCreationFailed)?,
-        None => invoke(
-            &ix,
-            &[
-                metadata.clone(),
-                mint.clone(),
-                authority.clone(),
-                payer.clone(),
-                system_program.clone(),
-                rent.clone(),
-            ],
-        ).map_err(|_| UniversalNftError::MetadataCreationFailed)?,
-    }
+    match authority_signer_seeds {
+        Some(seeds) => anchor_lang::solana_program::program::invoke_signed(
+            &ix,
+            &[
+                metadata.clone(),        // metadata
+                mint.clone(),            // mint
+                authority.clone(),       // mint_authority
+                payer.clone(),           // payer
+                authority.clone(),       // update_authority (same key, duplicated)
+                system_program.clone(),  // system_program
+                rent.clone(),            // rent
+            ],
+            seeds,
+        ).map_err(|_| UniversalNftError::MetadataCreationFailed)?,
+        None => invoke(
+            &ix,
+            &[
+                metadata.clone(),        // metadata
+                mint.clone(),            // mint
+                authority.clone(),       // mint_authority
+                payer.clone(),           // payer
+                authority.clone(),       // update_authority (same key, duplicated)
+                system_program.clone(),  // system_program
+                rent.clone(),            // rent
+            ],
+        ).map_err(|_| UniversalNftError::MetadataCreationFailed)?,
+    }

92-104: Fix CPI account order and max_supply type for CreateMasterEditionV3

Two issues:

  • Account slice must match builder metas and include both update_authority and mint_authority (even if same key). Current order omits one authority and shifts subsequent accounts.
  • Builder .max_supply expects Option (None for unlimited, Some(0) to disallow prints). Passing 0u64 is likely a type mismatch depending on mpl-token-metadata version.

Align with builder order: [edition, mint, update_authority, mint_authority, payer, metadata, token_program, system_program, rent] and set max_supply to Some(0) for a 1/1 NFT with no prints.

Apply this diff:

@@
-    let ix = CreateMasterEditionV3Builder::new()
+    let ix = CreateMasterEditionV3Builder::new()
         .edition(*master_edition.key)
         .mint(*mint.key)
         .update_authority(*authority.key)
         .mint_authority(*authority.key)
         .payer(*payer.key)
         .metadata(*metadata.key)
         .token_program(*token_program.key)
         .system_program(*system_program.key)
         .rent(Some(*rent.key))
-        .max_supply(0u64)
+        .max_supply(Some(0u64))
         .instruction();
@@
-        Some(seeds) => anchor_lang::solana_program::program::invoke_signed(
+        Some(seeds) => anchor_lang::solana_program::program::invoke_signed(
             &ix,
             &[
-                master_edition.clone(),
-                mint.clone(),
-                authority.clone(),
-                payer.clone(),
-                metadata.clone(),
-                token_program.clone(),
-                system_program.clone(),
-                rent.clone(),
+                master_edition.clone(),  // edition
+                mint.clone(),            // mint
+                authority.clone(),       // update_authority
+                authority.clone(),       // mint_authority (same key, duplicated)
+                payer.clone(),           // payer
+                metadata.clone(),        // metadata
+                token_program.clone(),   // token_program
+                system_program.clone(),  // system_program
+                rent.clone(),            // rent
             ],
             seeds,
         ).map_err(|_| UniversalNftError::MasterEditionCreationFailed)?,
         None => invoke(
             &ix,
             &[
-                master_edition.clone(),
-                mint.clone(),
-                authority.clone(),
-                payer.clone(),
-                metadata.clone(),
-                token_program.clone(),
-                system_program.clone(),
-                rent.clone(),
+                master_edition.clone(),  // edition
+                mint.clone(),            // mint
+                authority.clone(),       // update_authority
+                authority.clone(),       // mint_authority (same key, duplicated)
+                payer.clone(),           // payer
+                metadata.clone(),        // metadata
+                token_program.clone(),   // token_program
+                system_program.clone(),  // system_program
+                rent.clone(),            // rent
             ],
         ).map_err(|_| UniversalNftError::MasterEditionCreationFailed)?,

Also applies to: 105-133

contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1)

178-194: Allocate NftOrigin with discriminator space (8 bytes) to avoid OOB writes

You allocate only NftOrigin::LEN but later write discriminator + data (8 + LEN), which will overrun the buffer.

Apply this diff:

-            let rent = Rent::get()?;
-            let space = NftOrigin::LEN as u64;
-            let lamports = rent.minimum_balance(space as usize);
+            let rent = Rent::get()?;
+            let space = (8 + NftOrigin::LEN) as u64;
+            let lamports = rent.minimum_balance(space as usize);
🧹 Nitpick comments (1)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1)

129-144: Idempotency of NftOrigin creation vs. PR objective

The PR summary claims NftOrigin is created idempotently. Here, if the account already exists and is owned by this program, you error with NftAlreadyExists. Consider making this idempotent by verifying the existing data matches the intended values and then proceeding without error.

I can draft a patch that loads existing NftOrigin, validates it matches (token_id, mint, metadata, uri), and no-ops if so.

📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 14ab786 and 78c5c59.

📒 Files selected for processing (6)
  • contracts/nft/contracts/example/SolanaUniversalNFT.ts (1 hunks)
  • contracts/nft/contracts/solana/package.json (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/Cargo.toml (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/metaplex_helpers.rs (1 hunks)
  • contracts/nft/contracts/solana/tsconfig.json (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • contracts/nft/contracts/solana/tsconfig.json
  • contracts/nft/contracts/solana/package.json
  • contracts/nft/contracts/solana/programs/universal-nft/Cargo.toml
🧰 Additional context used
🧬 Code Graph Analysis (1)
contracts/nft/contracts/solana/programs/universal-nft/src/util/metaplex_helpers.rs (1)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)
  • mint (110-161)
🔇 Additional comments (4)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (2)

203-219: Copy length assumes discriminator + data; ensure allocation fix applies

This region copies 8 + serialized_len bytes into the account buffer. With the allocation fix above, this write becomes safe. Without it, it corrupts memory and fails at runtime.

After applying the allocation change at Lines 178-194, re-run tests or a local integration to ensure account creation + write succeeds for mint_nft.


331-359: Manually encoded Gateway.call payload: verify ABI and field order against the Gateway IDL

The discriminant and Borsh encodings look plausible, but this is brittle. If Gateway’s IDL changes (field order or types), this CPI will break. Prefer encoding via the Gateway’s Anchor IDL/program client or verify against the canonical IDL.

Would you like me to wire in the Gateway IDL and generate the instruction data using Anchor’s coder to avoid manual packing?

contracts/nft/contracts/example/SolanaUniversalNFT.ts (2)

163-174: Good client-side validation for destinationChain and finalRecipient

The added checks align with on-chain constraints (u64 range, recipient length ≤ 128) and will improve UX before RPC calls.


136-155: Ensure accountsStrict matches updated on-chain context

mintNft accounts include associatedTokenProgram and metadataProgram. The on-chain helpers use the TOKEN_METADATA_PROGRAM_ID constant and don’t need metadataProgram directly; associatedTokenProgram is only required if the program/Accounts init_if_needed creates the ATA.

Confirm that the on-chain MintNft context expects both associatedTokenProgram and metadataProgram in its accounts list; otherwise accountsStrict will reject these extras. I can adjust the CLI or the program Accounts definition once you confirm the IDL.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (2)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (2)

146-157: Master edition CPI now correctly passes the SPL Token program — good catch

Both call sites use token_program for CreateMasterEditionV3 as required by the updated helper signature.

Also applies to: 484-496


178-201: NftOrigin account sizing looks correct given NFT_ORIGIN_SPACE includes the discriminator

Allocations use NftOrigin::SPACE, and serialization writes discriminator + data. This matches the constant that already accounts for the 8-byte discriminator per prior clarification.

Also applies to: 517-543

🧹 Nitpick comments (3)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (3)

206-218: Minor: trim vector capacity to avoid confusion

Vec::with_capacity(8 + NftOrigin::LEN) over-allocates. Capacity = NftOrigin::LEN is sufficient since you write exactly discriminator(8) + struct bytes (= LEN total).

-        let mut tmp = Vec::with_capacity(8 + NftOrigin::LEN);
+        let mut tmp = Vec::with_capacity(NftOrigin::LEN);

and similarly in the on_call path:

-            let mut tmp = Vec::with_capacity(8 + NftOrigin::LEN);
+            let mut tmp = Vec::with_capacity(NftOrigin::LEN);

Also applies to: 548-555


614-637: Optional hardening: require presence of new gateway accounts when updating config

update_config optionally validates the provided new_gateway_program/new_gateway_pda if the accounts are present. To keep the gateway pin strong, consider requiring the corresponding accounts in remaining_accounts whenever those fields are updated, and validate executability/ownership deterministically.

Example approach:

  • If new_gateway_program.is_some() → require the program account be present and executable.
  • If new_gateway_pda.is_some() → require the account be present and owned by the (possibly updated) gateway program.

398-406: Naming nit: ctx.accounts.pda is the config account — consider renaming for clarity

In this context, pda is the UniversalNftConfig PDA. Renaming it to config (and keeping a separate config_pda bump if needed) will reduce cognitive load.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 78c5c59 and 8fe2fa7.

📒 Files selected for processing (7)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/initialize.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/reserve_next_token_id.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/config.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/mint_ticket.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/mint_ticket.rs
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/initialize.rs
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/reserve_next_token_id.rs
  • contracts/nft/contracts/solana/programs/universal-nft/src/state/config.rs
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs:178-194
Timestamp: 2025-08-22T12:08:58.785Z
Learning: In the Solana universal-nft program, NFT_ORIGIN_SPACE constant already includes the 8-byte Anchor discriminator in its calculation (8 + 32 + 32 + 32 + 4 + MAX_URI_LENGTH + 8 + 8 + 1 + 1), and NftOrigin::LEN correctly uses this value for account space allocation.
📚 Learning: 2025-08-22T12:08:58.785Z
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs:178-194
Timestamp: 2025-08-22T12:08:58.785Z
Learning: In the Solana universal-nft program, NFT_ORIGIN_SPACE constant already includes the 8-byte Anchor discriminator in its calculation (8 + 32 + 32 + 32 + 4 + MAX_URI_LENGTH + 8 + 8 + 1 + 1), and NftOrigin::LEN correctly uses this value for account space allocation.

Applied to files:

  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs
🧬 Code graph analysis (1)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (4)
contracts/nft/contracts/solana/programs/universal-nft/src/util/metaplex_helpers.rs (2)
  • create_metadata_account (15-77)
  • create_master_edition_account (80-136)
contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (1)
  • decode_nft_data_with_recipient (44-78)
contracts/nft/contracts/solana/programs/universal-nft/src/util/mint_helpers.rs (1)
  • mint_nft_to_recipient (9-39)
contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs (1)
  • new (40-58)
🔇 Additional comments (3)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (3)

268-277: ATA checks are solid

Strict ATA derivation and key equality checks for owner and mint are correctly enforced prior to burning.


132-157: Mint flow sequencing and early ticket-use are correct

Marking the MintTicket as used before side effects, followed by metadata and master edition creation, reduces double-spend risk. Good supply and decimals checks, too.


33-44: Gateway pinning during initialize is robust

You validate the gateway program’s key and executability and pin the gateway_pda by owner — aligns with the stated security model.

Comment on lines 416 to 431
let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
-1,
ix_account,
).map_err(|_| UniversalNftError::InvalidCaller)?;

require!(
current_ix.program_id == ctx.accounts.pda.gateway_program,
UniversalNftError::InvalidCaller
);

// Optionally assert gateway PDA owner
require!(
*ctx.accounts.gateway_pda.owner == ctx.accounts.pda.gateway_program,
UniversalNftError::InvalidCaller
);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Fix gateway caller verification: use current instruction (0), also assert the pinned gateway PDA is present

Using get_instruction_relative(-1) checks the previous top-level instruction, not the current one executing your CPI. An attacker can place a benign gateway instruction earlier in the transaction and then CPI into on_call from a different program to bypass this check. Verify the current instruction (0) and ensure it includes the pinned gateway_pda from config.

Run this to find any other unsafe uses:

Proposed patch:

-        let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
-            -1,
-            ix_account,
-        ).map_err(|_| UniversalNftError::InvalidCaller)?;
-        
-        require!(
-            current_ix.program_id == ctx.accounts.pda.gateway_program,
-            UniversalNftError::InvalidCaller
-        );
+        // IMPORTANT: When this program is invoked via CPI by the gateway, the "current"
+        // top-level instruction is the gateway instruction itself.
+        let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
+            0,
+            ix_account,
+        ).map_err(|_| UniversalNftError::InvalidCaller)?;
+
+        require_keys_eq!(
+            current_ix.program_id,
+            ctx.accounts.pda.gateway_program,
+            UniversalNftError::InvalidCaller
+        );
+
+        // Ensure the pinned gateway PDA is included among the accounts of the invoking instruction.
+        let pinned_gw_pda = ctx.accounts.pda.gateway_pda;
+        let gw_pda_present = current_ix.accounts.iter().any(|meta| meta.pubkey == pinned_gw_pda);
+        require!(gw_pda_present, UniversalNftError::InvalidCaller);
 
         // Optionally assert gateway PDA owner
         require!(
             *ctx.accounts.gateway_pda.owner == ctx.accounts.pda.gateway_program,
             UniversalNftError::InvalidCaller
         );

🏁 Script executed:

#!/bin/bash
rg -nP -C2 'get_instruction_relative\s*\(\s*-1' --type=rust

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Search for occurrences of ix_account in Rust files to verify the referenced sysvar account is correct
rg -n "ix_account" --type=rust -C2

# Verify any uses of get_instruction_relative (with any offset) to ensure no other unsafe calls remain
rg -nP "get_instruction_relative" --type=rust -C2

Length of output: 1717


Fix gateway caller verification: use current instruction (0) and assert pinned gateway PDA presence

The call to get_instruction_relative(-1) reads the previous top-level instruction, allowing an attacker to “preload” a gateway call earlier in the transaction and then CPI into on_call from a different program to bypass the check. Instead, inspect the current instruction (0) and ensure the pinned gateway PDA from your config is actually one of its accounts.

• Location to update:

  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs around lines 416–431

Proposed patch:

-        let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
-            -1,
-            ix_account,
-        ).map_err(|_| UniversalNftError::InvalidCaller)?;
-        
-        require!(
-            current_ix.program_id == ctx.accounts.pda.gateway_program,
-            UniversalNftError::InvalidCaller
-        );
+        // IMPORTANT: use offset 0 to inspect the *current* top-level instruction.
+        let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
+            0,
+            ix_account,
+        ).map_err(|_| UniversalNftError::InvalidCaller)?;
+
+        // Ensure it’s issued by the configured gateway program
+        require_keys_eq!(
+            current_ix.program_id,
+            ctx.accounts.pda.gateway_program,
+            UniversalNftError::InvalidCaller
+        );
+
+        // And verify the pinned gateway PDA is among its account metas
+        let pinned_gw = ctx.accounts.pda.gateway_pda;
+        let has_pinned = current_ix
+            .accounts
+            .iter()
+            .any(|meta| meta.pubkey == pinned_gw);
+        require!(has_pinned, UniversalNftError::InvalidCaller);
 
         // Optionally assert that the gateway PDA’s owner matches the gateway program
         require!(
             *ctx.accounts.gateway_pda.owner == ctx.accounts.pda.gateway_program,
             UniversalNftError::InvalidCaller
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
-1,
ix_account,
).map_err(|_| UniversalNftError::InvalidCaller)?;
require!(
current_ix.program_id == ctx.accounts.pda.gateway_program,
UniversalNftError::InvalidCaller
);
// Optionally assert gateway PDA owner
require!(
*ctx.accounts.gateway_pda.owner == ctx.accounts.pda.gateway_program,
UniversalNftError::InvalidCaller
);
// IMPORTANT: use offset 0 to inspect the *current* top-level instruction.
let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
0,
ix_account,
).map_err(|_| UniversalNftError::InvalidCaller)?;
// Ensure it’s issued by the configured gateway program
require_keys_eq!(
current_ix.program_id,
ctx.accounts.pda.gateway_program,
UniversalNftError::InvalidCaller
);
// And verify the pinned gateway PDA is among its account metas
let pinned_gw = ctx.accounts.pda.gateway_pda;
let has_pinned = current_ix
.accounts
.iter()
.any(|meta| meta.pubkey == pinned_gw);
require!(has_pinned, UniversalNftError::InvalidCaller);
// Optionally assert that the gateway PDA’s owner matches the gateway program
require!(
*ctx.accounts.gateway_pda.owner == ctx.accounts.pda.gateway_program,
UniversalNftError::InvalidCaller
);
🤖 Prompt for AI Agents
In contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs around lines
416–431, the code reads the previous top-level instruction with
get_instruction_relative(-1) which allows an attacker to preload a gateway call;
change to get_instruction_relative(0) to inspect the current instruction, keep
the program_id == ctx.accounts.pda.gateway_program check, and add an explicit
assertion that the pinned gateway PDA (ctx.accounts.gateway_pda.key()) is
present among the current instruction's account metas (iterate
current_ix.accounts or check account list for the PDA pubkey) so the gateway PDA
from config is actually one of the current instruction accounts before
proceeding.

@alperimo alperimo marked this pull request as ready for review August 26, 2025 22:03
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (3)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (3)

478-492: Fix gateway caller verification in on_call: use current instruction (0) and assert pinned Gateway PDA is present

Using get_instruction_relative(-1) checks the previous top-level instruction. An attacker can front-load a benign gateway instruction and then CPI into on_call from a different program to bypass this check. Verify the current instruction (0) instead, and also ensure the pinned gateway PDA from config is one of the current instruction’s account metas.

Apply this diff:

-        let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
-            -1,
-            ix_account,
-        ).map_err(|_| UniversalNftError::InvalidCaller)?;
-        
-        require!(
-            current_ix.program_id == ctx.accounts.pda.gateway_program,
-            UniversalNftError::InvalidCaller
-        );
+        // IMPORTANT: inspect the current top-level instruction (offset 0)
+        let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
+            0,
+            ix_account,
+        ).map_err(|_| UniversalNftError::InvalidCaller)?;
+
+        require_keys_eq!(
+            current_ix.program_id,
+            ctx.accounts.pda.gateway_program,
+            UniversalNftError::InvalidCaller
+        );
+
+        // Ensure the pinned gateway PDA is present among the current instruction's accounts
+        let pinned_gw_pda = ctx.accounts.pda.gateway_pda;
+        let gw_pda_present = current_ix.accounts.iter().any(|meta| meta.pubkey == pinned_gw_pda);
+        require!(gw_pda_present, UniversalNftError::InvalidCaller);

Run to ensure there are no other unsafe uses:

#!/bin/bash
# Find any other uses of get_instruction_relative with negative offsets
rg -nP --type=rust -C2 'get_instruction_relative\s*\(\s*-\d+\s*,'

282-287: Verify the provided gateway_pda equals the pinned one in config (not just owned by gateway program)

Ownership-only checks allow any account owned by the gateway program to be passed. Require exact key equality with the pinned config.gateway_pda to prevent misrouting.

Apply this diff:

         require!(
             *ctx.accounts.gateway_pda.owner == ctx.accounts.config.gateway_program,
             UniversalNftError::InvalidGatewayProgram
         );
+        require_keys_eq!(
+            ctx.accounts.gateway_pda.key(),
+            ctx.accounts.config.gateway_pda,
+            UniversalNftError::InvalidGatewayProgram
+        );

617-625: Make on_call mint idempotent and enforce supply invariants

on_call always mints 1, which can inflate supply if retried or called twice while the token is already on Solana. Enforce decimals==0 and supply/balance invariants; mint only when supply is 0, otherwise assert the invariant state.

Apply this diff:

-        // 3) Mint 1 to program_ata using config PDA as authority
-        mint_nft_to_recipient(
-            &ctx.accounts.mint_account,
-            &ctx.accounts.pda_ata,
-            &ctx.accounts.pda.to_account_info(),
-            &ctx.accounts.token_program,
-            Some(signer_seeds),
-        )?;
+        // 3) Enforce supply invariants and mint idempotently to program_ata
+        require!(ctx.accounts.mint_account.decimals == 0, UniversalNftError::InvalidMint);
+        if ctx.accounts.mint_account.supply == 0 {
+            mint_nft_to_recipient(
+                &ctx.accounts.mint_account,
+                &ctx.accounts.pda_ata,
+                &ctx.accounts.pda.to_account_info(),
+                &ctx.accounts.token_program,
+                Some(signer_seeds),
+            )?;
+        } else {
+            // Already minted to Solana: enforce idempotency invariants
+            require!(ctx.accounts.mint_account.supply == 1, UniversalNftError::InvalidTokenSupply);
+            require!(ctx.accounts.pda_ata.amount == 1, UniversalNftError::InvalidTokenAmount);
+        }
🧹 Nitpick comments (17)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (4)

494-496: Enforce inbound length caps in on_call for parity with mint_nft

Decode accepts untrusted strings (name, symbol, metadata_uri). Mirror the MAX_* caps used in mint_nft to keep invariants consistent and avoid oversized inputs.

Apply this diff:

-        let (token_id, origin_chain, _recipient_bytes, metadata_uri, name, symbol) = decode_nft_data_with_recipient(&data)?;
+        let (token_id, origin_chain, _recipient_bytes, metadata_uri, name, symbol) =
+            decode_nft_data_with_recipient(&data)?;
+        require!(name.len() <= MAX_NAME_LENGTH, UniversalNftError::NameTooLong);
+        require!(symbol.len() <= MAX_SYMBOL_LENGTH, UniversalNftError::SymbolTooLong);
+        require!(metadata_uri.len() <= MAX_URI_LENGTH, UniversalNftError::UriTooLong);

370-422: Gate verbose CPI diagnostics behind a feature flag (or remove before shipping)

The large hex/log blocks are great for debugging but add compute/log noise and leak payloads. Gate them behind #[cfg(feature = "verbose-cpi-logs")] or remove.

Suggested wrap (condensed for brevity):

-        // TODO: Remove: Diagnostics: Print everything needed for the CPI invoke ---
-        // Hex helpers
-        let to_hex = |bytes: &[u8]| -> String {
-            let mut s = String::with_capacity(bytes.len() * 2);
-            for b in bytes {
-                use core::fmt::Write as _;
-                let _ = write!(&mut s, "{:02x}", b);
-            }
-            s
-        };
-        let receiver_hex = to_hex(&receiver_bytes);
-        let token_id_hex = to_hex(&token_id);
-        let msg_hex = to_hex(&cross_chain_message);
-        msg!( ... many msgs ... );
+        #[cfg(feature = "verbose-cpi-logs")]
+        {
+            // Diagnostics: print CPI details and ABI message
+            let to_hex = |bytes: &[u8]| -> String {
+                let mut s = String::with_capacity(bytes.len() * 2);
+                for b in bytes {
+                    use core::fmt::Write as _;
+                    let _ = write!(&mut s, "{:02x}", b);
+                }
+                s
+            };
+            let receiver_hex = to_hex(&receiver_bytes);
+            let token_id_hex = to_hex(&token_id);
+            let msg_hex = to_hex(&cross_chain_message);
+            let dest_hex = if is_stay_on_zetachain {
+                String::from("0x0000000000000000000000000000000000000000")
+            } else { to_hex(&final_destination_zrc20) };
+            msg!("CPI invoke -> program_id (gateway): {}", ctx.accounts.config.gateway_program);
+            msg!("  Accounts[0] owner: {}, signer", owner_info.key());
+            msg!("  Accounts[1] gateway_pda: {}", gateway_pda_info.key());
+            msg!("  Args.receiver: 0x{}", receiver_hex);
+            msg!("  Args.message: destination={}, receiver=0x{}, tokenId=0x{}, uri='{}', sender=0x{}", dest_hex, to_hex(&receiver_addr), token_id_hex, uri, to_hex(&[0u8;20]));
+            msg!("  Encoded message len: {} (0x{})", cross_chain_message.len(), msg_hex);
+        }

150-160: Comment is misleading: helper does not create ATAs

mint_nft_to_recipient only mints; it doesn’t create an ATA. Either create the ATA before mint or update the comment to reflect expectations.

Apply this diff:

-            // Mint NFT to recipient (will create ATA if helper does so)
+            // Mint NFT to recipient (expects recipient ATA to pre-exist)

672-694: Avoid inconsistent gateway configuration: require new_gateway_pda when changing gateway program

If new_gateway_program is set without a matching new_gateway_pda in the same tx, the config can become unusable until a second update. Require both to be provided atomically.

Apply this diff:

         // Validate and update gateway program if requested
         if let Some(gateway) = new_gateway_program {
             // Disallow zero pubkey; optionally check executability if the account is provided
             require!(gateway != Pubkey::default(), UniversalNftError::InvalidGatewayProgram);
+            // Ensure the new gateway PDA is provided in the same transaction to keep config consistent
+            require!(new_gateway_pda.is_some(), UniversalNftError::InvalidGatewayProgram);
             if let Some(acc) = ctx.remaining_accounts.iter().find(|a| a.key() == gateway) {
                 // If the caller supplied the gateway account, ensure it's a program (executable)
                 require!(acc.executable, UniversalNftError::InvalidGatewayProgram);
             }
             if gateway != config.gateway_program {
                 config.gateway_program = gateway;
             }
         }
contracts/nft/scripts/solana_devnet_nft.sh (1)

75-77: Consider graceful degradation for missing ts-node.

Instead of exiting immediately, consider providing installation instructions or attempting to install ts-node automatically.

 # Ensure ts-node is available
 if ! npx --yes ts-node --version >/dev/null 2>&1; then
-  echo -e "${RED}ts-node is required (dev dependency). Run: npm i -D ts-node typescript${NC}"; exit 1;
+  echo -e "${YELLOW}ts-node not found. Installing...${NC}"
+  npm install -D ts-node typescript || {
+    echo -e "${RED}Failed to install ts-node. Please run: npm i -D ts-node typescript${NC}"
+    exit 1
+  }
 fi
contracts/nft/contracts/example/SolanaUniversalNFT.ts (12)

15-17: Make RPC endpoint configurable via environment variables

Defaulting to devnet is fine for examples, but a CLI benefits from env overrides to switch clusters without code changes.

Apply this diff to allow overrides via SOLANA_RPC_URL or ANCHOR_PROVIDER_URL:

-const SOLANA_DEVNET_RPC = "https://api.devnet.solana.com";
+const SOLANA_DEVNET_RPC = process.env.SOLANA_RPC_URL
+  || process.env.ANCHOR_PROVIDER_URL
+  || "https://api.devnet.solana.com";

41-48: Support custom wallet path via env and improve error message

Hardcoding ~/.config/solana/id.json limits CI/dev flows. Allow an override and guide the user when missing.

-  private loadWallet(): Keypair {
-    const walletPath = path.join(homedir(), ".config", "solana", "id.json");
-    if (!fs.existsSync(walletPath)) {
-      throw new Error("Wallet not found at ~/.config/solana/id.json");
-    }
+  private loadWallet(): Keypair {
+    const walletPath = process.env.SOLANA_WALLET
+      || path.join(homedir(), ".config", "solana", "id.json");
+    if (!fs.existsSync(walletPath)) {
+      throw new Error(`Wallet not found at ${walletPath}. Set SOLANA_WALLET to a keypair JSON path or create one via: solana-keygen new -o ${walletPath}`);
+    }
     const walletData = JSON.parse(fs.readFileSync(walletPath, "utf8"));
     return Keypair.fromSecretKey(new Uint8Array(walletData));
   }

50-57: Initialize Program with explicit programId from IDL and assert address consistency

Anchoring the programId to the IDL’s address prevents subtle IDL/program mismatches at runtime.

-  private loadProgram(): Program<UniversalNft> {
-    const provider = new anchor.AnchorProvider(
-      this.connection,
-      new anchor.Wallet(this.wallet),
-      { commitment: "confirmed" }
-    );
-    return new Program(idl as UniversalNft, provider) as Program<UniversalNft>;
-  }
+  private loadProgram(): Program<UniversalNft> {
+    const provider = new anchor.AnchorProvider(
+      this.connection,
+      new anchor.Wallet(this.wallet),
+      { commitment: "confirmed" }
+    );
+    const idlAddr = (idl as any)?.address;
+    if (!idlAddr) {
+      throw new Error("IDL is missing 'address'; ensure you built the IDL for the deployed program.");
+    }
+    const programId = new PublicKey(idlAddr);
+    const program = new Program(idl as anchor.Idl, programId, provider) as Program<UniversalNft>;
+    if (!program.programId.equals(programId)) {
+      throw new Error(`Program ID mismatch: Program(${program.programId.toBase58()}) vs IDL(${programId.toBase58()})`);
+    }
+    return program;
+  }

138-146: Add client-side caps for name/symbol/uri to fail-fast before on-chain errors

The on-chain program enforces input caps. Validate early to save users from paying for failing transactions.

   async mint(uri: string, name = "Universal NFT", symbol = "UNFT"): Promise<void> {
+    // Basic caps to mirror typical Metaplex/program expectations
+    const nameBytes = Buffer.byteLength(name, "utf8");
+    const symbolBytes = Buffer.byteLength(symbol, "utf8");
+    const uriBytes = Buffer.byteLength(uri, "utf8");
+    if (nameBytes === 0 || nameBytes > 32) throw new Error(`name must be 1..32 bytes (got ${nameBytes})`);
+    if (symbolBytes === 0 || symbolBytes > 10) throw new Error(`symbol must be 1..10 bytes (got ${symbolBytes})`);
+    if (uriBytes === 0 || uriBytes > 200) throw new Error(`uri must be 1..200 bytes (got ${uriBytes})`);

If your on-chain constants differ, adjust the limits above accordingly.


164-167: Consider setting a compute unit price for more reliable inclusion

You already boost the CU limit. Setting a modest CU price helps avoid CU exhaustion/priority starvation under load.

 const computeIxs = [
   ComputeBudgetProgram.setComputeUnitLimit({ units: 1_000_000 }),
+  // 1,000 micro-lamports per CU (~0.000001 SOL per 1k CU). Tune as needed.
+  ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1_000 }),
 ];

246-255: Fail-fast if NFT is already off-Solana

Users get a clearer error before burning fails on-chain.

   const origin = await this.program.account.nftOrigin.fetch(nftOriginPda);
   const mint = origin.originalMint as PublicKey;
   const metadataPda = origin.originalMetadata as PublicKey;
+  if (!(origin as any).isOnSolana) {
+    throw new Error("This NFT is currently off-Solana (bridged). Bring it back before initiating another transfer.");
+  }

256-263: Escalate IDL/program address mismatches to a hard error

You log potential mismatches but still proceed. Abort early to avoid confusing Buffer offset/arg-mismatch errors later.

   const idlAddr = (idl as any)?.address;
   if (idlAddr) {
     const idlInstr = (idl as any)?.instructions?.find((i: any) => i.name === 'transfer_to_zetachain' || i.name === 'transferToZetachain');
     console.log('IDL program address:', idlAddr);
     console.log('Loaded program address:', this.program.programId.toBase58());
     if (idlInstr) console.log('IDL transferToZetachain args:', idlInstr.args?.map((a: any) => `${a.name}:${JSON.stringify(a.type)}`));
+    if (idlAddr !== this.program.programId.toBase58()) {
+      throw new Error(`IDL/program mismatch. Rebuild IDL (anchor build) and/or point the CLI to the correct deployed program.`);
+    }
   }

233-237: Broaden finalRecipient validation: accept 20-byte EVM hex OR ASCII ≤ 128 bytes

On-chain only caps the length (≤ 128). Restricting to hex EVM addresses limits flexibility. Accept either a 20-byte 0xhex or a non-empty ASCII string ≤ 128 bytes. Also update the usage message and pass the normalized value to the method.

Apply these diffs:

  1. Validation and normalization
-    if (!finalRecipient) throw new Error("finalRecipient must be provided as a 20-byte EVM address (hex)");
-    const recRaw = finalRecipient.trim();
-    const recBuf = Buffer.from(recRaw.replace(/^0x/, ''), 'hex');
-    if (recBuf.length !== 20) throw new Error(`finalRecipient must be a 20-byte hex EVM address (got ${recBuf.length} bytes)`);
+    if (!finalRecipient) throw new Error("finalRecipient must be provided");
+    const recRaw = finalRecipient.trim();
+    let finalRecipientNormalized = recRaw;
+    const hexMatch = recRaw.replace(/^0x/, "");
+    if (/^[0-9a-fA-F]{40}$/.test(hexMatch)) {
+      // Normalize to 0x-prefixed lowercased hex
+      finalRecipientNormalized = "0x" + hexMatch.toLowerCase();
+    } else {
+      const ascii = Buffer.from(recRaw, "utf8");
+      if (ascii.length === 0 || ascii.length > 128) {
+        throw new Error(`finalRecipient must be a 20-byte hex address or an ASCII string ≤ 128 bytes (got ${ascii.length} bytes)`);
+      }
+    }
  1. Use the normalized value in the RPC call
-          finalRecipient,
+          finalRecipientNormalized,
  1. Update CLI help text
-  console.log("Commands:\n  initialize <gatewayProgramPubkey> <gatewayPdaPubkey>\n  update-config <newGatewayProgramPubkey|- or empty> <newGatewayPdaPubkey|- or empty> [newAuthorityPubkey|-] [pause true|false|-]\n  mint <uri> [name] [symbol]\n  transfer <tokenIdHex32> <zcUniversalContractHex20> <destZrc20Hex20|'0'> <finalRecipient> [depositSol default 0.02]\n  status\n  origin <tokenIdHex32>\n  balance");
+  console.log("Commands:\n  initialize <gatewayProgramPubkey> <gatewayPdaPubkey>\n  update-config <newGatewayProgramPubkey|- or empty> <newGatewayPdaPubkey|- or empty> [newAuthorityPubkey|-] [pause true|false|-]\n  mint <uri> [name] [symbol]\n  transfer <tokenIdHex32> <zcUniversalContractHex20> <destZrc20Hex20|'0'> <finalRecipient(20-byte 0xhex OR ASCII ≤128)> [depositSol default 0.02]\n  status\n  origin <tokenIdHex32>\n  balance");

Also applies to: 275-281, 407-407


294-297: Optional: add compute unit price alongside limit for the transfer

Same rationale as in mint(): give TX some priority headroom when burning + CPI to Gateway under load.

 .preInstructions([
   // Give headroom for burn + gateway CPI
   ComputeBudgetProgram.setComputeUnitLimit({ units: 600_000 }),
+  ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1_000 }),
 ])

347-349: Validate tokenId length in origin() like you do in transfer()

Consistent validation improves UX.

   async origin(tokenIdHex: string): Promise<void> {
-    const tokenId = Buffer.from(tokenIdHex.replace(/^0x/, ''), 'hex');
+    const tokenId = Buffer.from(tokenIdHex.replace(/^0x/, ''), 'hex');
+    if (tokenId.length !== 32) throw new Error(`tokenId must be 32 bytes hex (got ${tokenId.length})`);

360-365: Ensure non-zero exit on early constructor failures and avoid double-logging

Errors thrown before the try/catch (e.g., missing wallet) currently won’t set a non-zero exit code. Also, main().catch only logs. Move CLI construction into the try and set exitCode in the top-level catch.

 async function main() {
   const [cmd, ...args] = process.argv.slice(2);
-  const cli = new UniversalNftCLI();
   try {
+    const cli = new UniversalNftCLI();
-if (require.main === module) {
-  main().catch(console.error);
-}
+if (require.main === module) {
+  main().catch((e) => {
+    console.error(e);
+    process.exitCode = 1;
+  });
+}

Also applies to: 416-418


197-201: Minor: remove unused helpers if no longer needed

toU64LE and sha256 are currently unused. If they’re not slated for imminent use, consider removing them to reduce noise.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 85439e2 and 1edf296.

⛔ Files ignored due to path filters (2)
  • contracts/nft/contracts/solana/Cargo.lock is excluded by !**/*.lock
  • contracts/nft/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (3)
  • contracts/nft/contracts/example/SolanaUniversalNFT.ts (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1 hunks)
  • contracts/nft/scripts/solana_devnet_nft.sh (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs:7-0
Timestamp: 2025-08-20T06:48:48.341Z
Learning: In Anchor Solana programs, the #[account] macro automatically implements AnchorSerialize, AnchorDeserialize, AccountSerialize, AccountDeserialize, Discriminator, Owner, and Clone traits for structs. Explicit derives for serialization are not needed and would be redundant for account data structures marked with #[account].
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs:178-194
Timestamp: 2025-08-22T12:08:58.842Z
Learning: In the Solana universal-nft program, NFT_ORIGIN_SPACE constant already includes the 8-byte Anchor discriminator in its calculation (8 + 32 + 32 + 32 + 4 + MAX_URI_LENGTH + 8 + 8 + 1 + 1), and NftOrigin::LEN correctly uses this value for account space allocation.
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs:7-0
Timestamp: 2025-08-20T06:48:48.341Z
Learning: In Anchor Solana programs, the #[account] macro automatically implements AnchorSerialize and AnchorDeserialize traits for structs, so explicit derives are not needed for account data structures.
📚 Learning: 2025-08-22T12:08:58.842Z
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs:178-194
Timestamp: 2025-08-22T12:08:58.842Z
Learning: In the Solana universal-nft program, NFT_ORIGIN_SPACE constant already includes the 8-byte Anchor discriminator in its calculation (8 + 32 + 32 + 32 + 4 + MAX_URI_LENGTH + 8 + 8 + 1 + 1), and NftOrigin::LEN correctly uses this value for account space allocation.

Applied to files:

  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs
🧬 Code graph analysis (2)
contracts/nft/scripts/solana_devnet_nft.sh (2)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (2)
  • initialize (94-108)
  • mint (138-209)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1)
  • initialize (28-66)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (5)
contracts/nft/contracts/solana/programs/universal-nft/src/util/metaplex_helpers.rs (2)
  • create_metadata_account (15-81)
  • create_master_edition_account (84-144)
contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (2)
  • decode_nft_data_with_recipient (44-78)
  • hex_string_to_address (145-164)
contracts/nft/contracts/solana/programs/universal-nft/src/util/mint_helpers.rs (1)
  • mint_nft_to_recipient (9-39)
contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs (1)
  • new (40-58)
contracts/nft/contracts/solana/programs/universal-nft/src/util/gateway_helpers.rs (2)
  • encode_evm_nft_message (43-89)
  • encode_gateway_deposit_and_call_ix_data (23-35)
🔇 Additional comments (10)
contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (3)

190-225: NftOrigin allocation/serialization looks correct (accounts for discriminator)

You allocate using NftOrigin::SPACE and then serialize discriminator+data into the buffer. Per our prior learning, SPACE/LEN already includes the 8-byte Anchor discriminator. Good defensive zeroing before writes too.


129-144: Metadata creation ordering and idempotency checks look solid

Creating metadata before mint, checking data_is_empty, and later gating master edition by supply==1 matches Metaplex invariants. Passing token_program to master edition was fixed appropriately.


68-98: Reservation logic and deterministic token_id derivation look good

Correct use of slot and a monotonically-increasing reserved_id with checked_add; token_id derived as sha256(mint || slot_LE || reserved_id_LE) matches the documented formula.

contracts/nft/scripts/solana_devnet_nft.sh (5)

1-4: LGTM! Proper bash setup with error handling.

The script correctly uses set -euo pipefail for robust error handling and includes a clear purpose statement.


13-25: Robust environment variable loading.

The non-overriding .env loading mechanism is well-implemented, preventing environment variables from being accidentally overwritten while still providing sensible defaults.


88-92: Robust token ID extraction with proper error handling.

The awk-based parsing correctly handles the "Token ID: " prefix and includes proper validation to ensure the script fails fast if the minting step doesn't produce the expected output.


96-106: Excellent error handling for transfer operation.

The script properly captures both stdout/stderr and exit codes without immediately failing, allowing for comprehensive error reporting while still maintaining proper exit behavior.


29-35: Action Required: Manually Confirm Devnet Gateway Addresses

I wasn’t able to automatically retrieve the current Solana gateway program and PDA for ZetaChain Devnet. Please check the official ZetaChain Devnet docs or CLI output to confirm that these defaults are still correct:

  • GATEWAY_PROGRAM (currently ZETAjseVjuFsxdRxo6MmTCvqFwb3ZHUx56Co3vCmGis)
  • GATEWAY_PDA (currently 2f9SLuUNb7TNeM6gzBwT4ZjbL5ZyKzzHg1Ce9yiquEjj)

If they’ve changed, update the script accordingly to avoid misrouting NFT transactions.

contracts/nft/contracts/example/SolanaUniversalNFT.ts (2)

66-71: PDAs derivation looks correct and aligned with Metaplex and program seeds

Seeds and program IDs for config, metadata, master edition, origin, and tickets match the conventions described in the PR.

Also applies to: 73-78, 80-85, 87-92


197-207: Robust error surfacing with logs and stack traces

Good defensive logging for Anchor errors, including fetching logs from RpcResponseError. This will be valuable during testnet debugging.

Also applies to: 301-316

@fadeev
Copy link
Member

fadeev commented Aug 27, 2025

@alperimo yes! "Outbound mined" means a cross-chain transaction was successful, this is definitely a good sign. I can see the NFTs:

https://zetachain-testnet.blockscout.com/address/0xd1be16976Bd3A6282cC8B38ec7A9318C5a9e4F0A?tab=token_transfers

(I recommend using Blockscout).

@fadeev
Copy link
Member

fadeev commented Aug 27, 2025

Before we can proceed to reviewing the code, we need the following:

  • addresses of Universal NFT contracts deployed on Base Sepolia, ZetaChain, Solana devnet.
  • transaction hash of a cross-chain NFT transfer from Solana devnet to Base Sepolia
  • transaction hash on a cross-chain NFT transfer from Base Sepolia to Solana devnet
  • transaction hash on a cross-chain NFT transfer from ZetaChain testnet to Solana devnet

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (3)
contracts/nft/scripts/testnet_nft.sh (3)

32-39: Base/BNB deploys: mirror resiliency and allow gateway override; validate outputs.

Use retry, --no-install, env-overridable gateway, and address validation for both chains.

 echo "🚀 Deploying EVMUniversalNFT on Base Sepolia (nft:deploy)..."
-CONNECTED_BASE=$(npx hardhat nft:deploy --name EVMUniversalNFT --network base_sepolia --gateway 0x0c487a766110c85d301d96e33579c5b317fa4995 --json | jq -r '.contractAddress')
+CONNECTED_BASE=$(
+  retry 3 npx --no-install hardhat nft:deploy \
+    --name EVMUniversalNFT \
+    --network base_sepolia \
+    --gateway "${GATEWAY_BASE:-0x0c487a766110c85d301d96e33579c5b317fa4995}" \
+    --gasLimit "$GAS_LIMIT" \
+    --json | jq -r '.contractAddress'
+)
+is_eth_address "${CONNECTED_BASE}" || fail "Invalid Base Sepolia address: ${CONNECTED_BASE}"
 echo "Base Sepolia Universal NFT: $CONNECTED_BASE"
 
 echo "🚀 Deploying EVMUniversalNFT on BNB testnet (nft:deploy)..."
-CONNECTED_BNB=$(npx hardhat nft:deploy --name EVMUniversalNFT --network bsc_testnet --gateway 0x0c487a766110c85d301d96e33579c5b317fa4995 --json | jq -r '.contractAddress')
+CONNECTED_BNB=$(
+  retry 3 npx --no-install hardhat nft:deploy \
+    --name EVMUniversalNFT \
+    --network bsc_testnet \
+    --gateway "${GATEWAY_BNB:-0x0c487a766110c85d301d96e33579c5b317fa4995}" \
+    --gasLimit "$GAS_LIMIT" \
+    --json | jq -r '.contractAddress'
+)
+is_eth_address "${CONNECTED_BNB}" || fail "Invalid BNB testnet address: ${CONNECTED_BNB}"
 echo "BNB testnet Universal NFT: $CONNECTED_BNB"

47-54: Network wiring: add retry and --no-install; fail fast on errors.

External calls are flaky; wrap with retry and avoid auto-install.

 echo "🔗 Wiring connections (set-connected / set-universal)..."
-npx hardhat nft:set-connected --network zeta_testnet --contract "$UNIVERSAL" --connected "$CONNECTED_BASE" --zrc20 "$ZRC20_BASE" --json
-npx hardhat nft:set-connected --network zeta_testnet --contract "$UNIVERSAL" --connected "$CONNECTED_BNB" --zrc20 "$ZRC20_BNB" --json
-npx hardhat nft:set-connected --network zeta_testnet --contract "$UNIVERSAL" --connected "$CONNECTED_SOLANA" --zrc20 "$ZRC20_SOL" --json
+retry 3 npx --no-install hardhat nft:set-connected --network zeta_testnet --contract "$UNIVERSAL" --connected "$CONNECTED_BASE" --zrc20 "$ZRC20_BASE" --json
+retry 3 npx --no-install hardhat nft:set-connected --network zeta_testnet --contract "$UNIVERSAL" --connected "$CONNECTED_BNB" --zrc20 "$ZRC20_BNB" --json
+retry 3 npx --no-install hardhat nft:set-connected --network zeta_testnet --contract "$UNIVERSAL" --connected "$CONNECTED_SOLANA" --zrc20 "$ZRC20_SOL" --json
 
-npx hardhat nft:set-universal --network base_sepolia --contract "$CONNECTED_BASE" --universal "$UNIVERSAL" --json
-npx hardhat nft:set-universal --network bsc_testnet --contract "$CONNECTED_BNB" --universal "$UNIVERSAL" --json
+retry 3 npx --no-install hardhat nft:set-universal --network base_sepolia --contract "$CONNECTED_BASE" --universal "$UNIVERSAL" --json
+retry 3 npx --no-install hardhat nft:set-universal --network bsc_testnet --contract "$CONNECTED_BNB" --universal "$UNIVERSAL" --json

61-68: Minting: add retry/--no-install and validate token IDs (jq may return null).

Prevents silent downstream failures.

-BASE_MINT_JSON=$(npx hardhat nft:mint --network base_sepolia --contract "$CONNECTED_BASE" --token-uri "$NFT_URI" --name EVMUniversalNFT --gas-limit "$GAS_LIMIT" --json)
+BASE_MINT_JSON=$(retry 3 npx --no-install hardhat nft:mint --network base_sepolia --contract "$CONNECTED_BASE" --tokenUri "$NFT_URI" --name EVMUniversalNFT --gasLimit "$GAS_LIMIT" --json)
 BASE_TOKEN_ID=$(echo "$BASE_MINT_JSON" | jq -r '.tokenId')
+[[ -n "${BASE_TOKEN_ID}" && "${BASE_TOKEN_ID}" != "null" ]] || fail "Failed to parse Base tokenId: ${BASE_MINT_JSON}"
 echo "Base minted tokenId: $BASE_TOKEN_ID"
 
-BNB_MINT_JSON=$(npx hardhat nft:mint --network bsc_testnet --contract "$CONNECTED_BNB" --token-uri "$NFT_URI" --name EVMUniversalNFT --gas-limit "$GAS_LIMIT" --json)
+BNB_MINT_JSON=$(retry 3 npx --no-install hardhat nft:mint --network bsc_testnet --contract "$CONNECTED_BNB" --tokenUri "$NFT_URI" --name EVMUniversalNFT --gasLimit "$GAS_LIMIT" --json)
 BNB_TOKEN_ID=$(echo "$BNB_MINT_JSON" | jq -r '.tokenId')
+[[ -n "${BNB_TOKEN_ID}" && "${BNB_TOKEN_ID}" != "null" ]] || fail "Failed to parse BNB tokenId: ${BNB_MINT_JSON}"
 echo "BNB minted tokenId: $BNB_TOKEN_ID"
🧹 Nitpick comments (7)
contracts/nft/scripts/testnet_nft.sh (2)

42-46: Parameterize ZRC-20 addresses and validate.

Accept env overrides and ensure address format.

-# ZRC-20 references
-ZRC20_BASE=0x236b0DE675cC8F46AE186897fCCeFe3370C9eDeD
-ZRC20_BNB=0xd97B1de3619ed2c6BEb3860147E30cA8A7dC9891
-ZRC20_SOL=0xADF73ebA3Ebaa7254E859549A44c74eF7cff7501
+# ZRC-20 references (overridable)
+ZRC20_BASE=${ZRC20_BASE:-0x236b0DE675cC8F46AE186897fCCeFe3370C9eDeD}
+ZRC20_BNB=${ZRC20_BNB:-0xd97B1de3619ed2c6BEb3860147E30cA8A7dC9891}
+ZRC20_SOL=${ZRC20_SOL:-0xADF73ebA3Ebaa7254E859549A44c74eF7cff7501}
+is_eth_address "${ZRC20_BASE}" || fail "Invalid ZRC20_BASE"
+is_eth_address "${ZRC20_BNB}" || fail "Invalid ZRC20_BNB"
+is_eth_address "${ZRC20_SOL}" || fail "Invalid ZRC20_SOL"

4-4: Avoid leaking secrets in logs; gate xtrace by DEBUG.

Hardhat may read PKs from env; xtrace can echo them.

-set -x
+[[ -n "${DEBUG:-}" ]] && set -x
contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (4)

47-66: Unify address hex parsing via hex::decode (simpler, safer).

Avoid manual per-byte parsing and reuse the same crate you already use for encoding. Also returns clearer errors on invalid hex.

 pub fn hex_string_to_address(address_str: &str) -> Result<[u8; 20]> {
   let hex_str = if address_str.starts_with("0x") {
     &address_str[2..]
   } else {
     address_str
   };
 
   if hex_str.len() != 40 {
     return Err(UniversalNftError::InvalidRecipientAddress.into());
   }
 
-  let mut bytes = [0u8; 20];
-  for i in 0..20 {
-      let byte_str = &hex_str[i*2..i*2+2];
-      bytes[i] = u8::from_str_radix(byte_str, 16)
-          .map_err(|_| UniversalNftError::InvalidRecipientAddress)?;
-  }
-  
-  Ok(bytes)
+  let mut bytes = [0u8; 20];
+  let decoded = hex::decode(hex_str)
+      .map_err(|_| UniversalNftError::InvalidRecipientAddress)?;
+  if decoded.len() != 20 {
+      return Err(UniversalNftError::InvalidRecipientAddress.into());
+  }
+  bytes.copy_from_slice(&decoded);
+  Ok(bytes)
 }

84-144: Tighten ABI validations: check 32-byte alignment and minimum offset; remove magic numbers.

Prevents odd offsets and improves readability. Consider also capping URI length to your MAX_URI_LENGTH to avoid large allocations.

 pub fn decode_evm_abi_nft_message(data: &[u8]) -> Result<([u8; 32], [u8; 20], String, u64, [u8; 20])> {
-    // Need at least 5 words for head and one word for string length
-    if data.len() < 32 * 6 {
+    // Need at least 5 words for head and one word for string length
+    const WORD: usize = 32;
+    const HEAD_WORDS: usize = 5;
+    if data.len() < WORD * (HEAD_WORDS + 1) {
         return Err(UniversalNftError::InvalidDataFormat.into());
     }
 
     // Helper to extract a 32-byte word at index
-    let word = |i: usize| -> &[u8] { &data[i * 32..(i + 1) * 32] };
+    let word = |i: usize| -> &[u8] { &data[i * WORD..(i + 1) * WORD] };
@@
-    let offset = u64::from_be_bytes(off_u64_bytes) as usize;
-    if offset + 32 > data.len() { // must have length word
+    let offset = u64::from_be_bytes(off_u64_bytes) as usize;
+    // dynamic tail must start at/after the head and be 32-byte aligned
+    if offset < WORD * HEAD_WORDS || (offset % WORD != 0) {
+        return Err(UniversalNftError::InvalidDataFormat.into());
+    }
+    if offset + WORD > data.len() { // must have length word
         return Err(UniversalNftError::InvalidDataFormat.into());
     }
@@
-    let len_word = &data[offset..offset + 32];
+    let len_word = &data[offset..offset + WORD];
@@
-    let start = offset + 32;
+    let start = offset + WORD;
     if start + uri_len > data.len() {
         return Err(UniversalNftError::InvalidDataFormat.into());
     }

Follow-up (no diff): consider rejecting URIs longer than your on-chain limit (e.g., crate::constants::MAX_URI_LENGTH).


7-30: These length-prefixed string helpers are currently unused; either use them or silence warnings.

If they’re kept for future use, add #[allow(dead_code)] (and consider a borrowed variant to avoid allocation).

 /// Extract a length-prefixed string from byte array
 /// Format: length(4 bytes, big-endian) + string_data
-fn extract_length_prefixed_string(data: &[u8], cursor: usize) -> Result<(String, usize)> {
+#[allow(dead_code)]
+fn extract_length_prefixed_string(data: &[u8], cursor: usize) -> Result<(String, usize)> {

34-43: Guard against length overflow; consider returning Result from encoder.

Casting usize to u32 can truncate for very large inputs. Make it fallible to avoid silent corruption (even if practically unlikely on Solana).

-fn encode_length_prefixed_string(data: &mut Vec<u8>, string: &str) {
+#[allow(dead_code)]
+fn encode_length_prefixed_string(data: &mut Vec<u8>, string: &str) -> Result<()> {
     let string_bytes = string.as_bytes();
-    let len = string_bytes.len() as u32;
+    if string_bytes.len() > u32::MAX as usize {
+        return Err(UniversalNftError::InvalidDataFormat.into());
+    }
+    let len = string_bytes.len() as u32;
@@
-    data.extend_from_slice(string_bytes);
+    data.extend_from_slice(string_bytes);
+    Ok(())
 }
contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs (1)

25-27: Validate mint invariants at account validation time.

Catch misconfigured mints early: decimals == 0 and mint_authority == pda.

Apply:

-    #[account(mut)]
-    pub mint_account: Account<'info, Mint>,
+    #[account(
+        mut,
+        constraint = mint_account.decimals == 0,
+        constraint = mint_account.mint_authority == anchor_lang::solana_program::program_option::COption::Some(pda.key())
+    )]
+    pub mint_account: Account<'info, Mint>,
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1edf296 and 7835c76.

📒 Files selected for processing (6)
  • contracts/nft/contracts/example/SolanaUniversalNFT.ts (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (1 hunks)
  • contracts/nft/scripts/testnet_nft.sh (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • contracts/nft/contracts/example/SolanaUniversalNFT.ts
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs:7-0
Timestamp: 2025-08-20T06:48:48.341Z
Learning: In Anchor Solana programs, the #[account] macro automatically implements AnchorSerialize, AnchorDeserialize, AccountSerialize, AccountDeserialize, Discriminator, Owner, and Clone traits for structs. Explicit derives for serialization are not needed and would be redundant for account data structures marked with #[account].
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs:178-194
Timestamp: 2025-08-22T12:08:58.842Z
Learning: In the Solana universal-nft program, NFT_ORIGIN_SPACE constant already includes the 8-byte Anchor discriminator in its calculation (8 + 32 + 32 + 32 + 4 + MAX_URI_LENGTH + 8 + 8 + 1 + 1), and NftOrigin::LEN correctly uses this value for account space allocation.
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs:7-0
Timestamp: 2025-08-20T06:48:48.341Z
Learning: In Anchor Solana programs, the #[account] macro automatically implements AnchorSerialize and AnchorDeserialize traits for structs, so explicit derives are not needed for account data structures.
📚 Learning: 2025-08-20T06:48:48.341Z
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs:7-0
Timestamp: 2025-08-20T06:48:48.341Z
Learning: In Anchor Solana programs, the #[account] macro automatically implements AnchorSerialize, AnchorDeserialize, AccountSerialize, AccountDeserialize, Discriminator, Owner, and Clone traits for structs. Explicit derives for serialization are not needed and would be redundant for account data structures marked with #[account].

Applied to files:

  • contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs
🔇 Additional comments (7)
contracts/nft/scripts/testnet_nft.sh (2)

18-26: Compile step is solid.

Good use of retry, actionable diagnostics, and IPv4-first hint.


28-31: Incorrect param naming concern
All flags in your deployment script use kebab-case and correctly correspond to the Hardhat task parameters (name, network, gateway, uniswap-router, json). There is no mix of camelCase flags here, so this check can be safely dismissed.

Likely an incorrect or invalid review comment.

contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (1)

68-71: Dependency Verification Complete: hex crate is declared

  • Confirmed usage of hex::encode in
    contracts/nft/contracts/solana/programs/universal-nft/src/util/cross_chain_helpers.rs (lines 69–71).
  • Confirmed declaration of hex = "0.4" in
    contracts/nft/contracts/solana/programs/universal-nft/Cargo.toml (line 23).

Optional UX Improvement
If the resulting hex string is displayed to end users, consider returning an EIP-55-checksummed address instead of all-lowercase hex for improved readability and error detection. Otherwise, the current lowercase output is perfectly acceptable for internal usage.

contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs (4)

9-13: Mark config PDA as mut if it funds lazy account creations.

If on_call lazily creates metadata/master edition and pays from the config PDA, pda must be mutable to debit lamports.

Apply this diff if pda funds creations:

 #[account(
-        seeds = [UNIVERSAL_NFT_CONFIG_SEED],
-        bump = pda.bump
+        mut, // needed if PDA pays fees for lazy init
+        seeds = [UNIVERSAL_NFT_CONFIG_SEED],
+        bump = pda.bump
 )]

If you don’t spend from the PDA, ignore this.


29-41: Metaplex Metadata PDA constraints look correct.


43-56: Master Edition PDA constraints look correct.


58-65: Gateway PDA pinning and ownership check — good.

This complements the runtime previous-instruction check.

Comment on lines +15 to +18
/// NFT origin account (PDA) derived as [NFT_ORIGIN_SEED, origin_chain.to_be_bytes(), token_id]
/// CHECK: Passed PDA must match derived address; we (re)initialize if empty
#[account(mut)]
pub nft_origin: UncheckedAccount<'info>,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Statically enforce nft_origin PDA derivation using #[instruction(...)] seeds.

This removes a runtime footgun and ensures the passed account matches the canonical PDA.

Apply:

-#[derive(Accounts)]
+#[derive(Accounts)]
+#[instruction(origin_chain: u64, token_id: [u8; 32])]
 pub struct OnCall<'info> {
@@
-    #[account(mut)]
-    pub nft_origin: UncheckedAccount<'info>,
+    #[account(
+        mut,
+        seeds = [
+            crate::util::constants::NFT_ORIGIN_SEED,
+            &origin_chain.to_be_bytes(),
+            token_id.as_ref(),
+        ],
+        bump
+    )]
+    pub nft_origin: UncheckedAccount<'info>,

And extend the import if needed:

-use crate::util::constants::{UNIVERSAL_NFT_CONFIG_SEED, TOKEN_METADATA_PROGRAM_ID};
+use crate::util::constants::{UNIVERSAL_NFT_CONFIG_SEED, NFT_ORIGIN_SEED, TOKEN_METADATA_PROGRAM_ID};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// NFT origin account (PDA) derived as [NFT_ORIGIN_SEED, origin_chain.to_be_bytes(), token_id]
/// CHECK: Passed PDA must match derived address; we (re)initialize if empty
#[account(mut)]
pub nft_origin: UncheckedAccount<'info>,
// Extend your imports to pull in the NFT_ORIGIN_SEED constant
use crate::util::constants::{UNIVERSAL_NFT_CONFIG_SEED, NFT_ORIGIN_SEED, TOKEN_METADATA_PROGRAM_ID};
#[derive(Accounts)]
// Declare the instruction arguments so we can reference them in the account constraints
#[instruction(origin_chain: u64, token_id: [u8; 32])]
pub struct OnCall<'info> {
/// NFT origin account (PDA) derived as [NFT_ORIGIN_SEED, origin_chain.to_be_bytes(), token_id]
/// CHECK: Passed PDA must match derived address; we (re)initialize if empty
#[account(
mut,
seeds = [
crate::util::constants::NFT_ORIGIN_SEED,
&origin_chain.to_be_bytes(),
token_id.as_ref(),
],
bump
)]
pub nft_origin: UncheckedAccount<'info>,
// …other accounts…
}
🤖 Prompt for AI Agents
In contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs
around lines 15-18, the nft_origin PDA is only runtime-checked; add a
compile-time PDA constraint by requiring the handler to declare the PDA seed
inputs with an #[instruction(...)] and annotate the account with seeds and bump.
Specifically, add an #[instruction(origin_chain: u64, token_id: Vec<u8>,
nft_origin_bump: u8)] to the instruction handler signature, change the account
attribute to #[account(mut, seeds = [NFT_ORIGIN_SEED,
origin_chain.to_be_bytes().as_ref(), token_id.as_ref()], bump =
nft_origin_bump)] on nft_origin, and import any needed Anchor attributes/types
if not already present.

Comment on lines +20 to +24
/// Program ATA to receive minted token (authority = config, mint = mint)
/// We validate at runtime that it matches the derived ATA
#[account(mut)]
pub pda_ata: Account<'info, TokenAccount>,

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enforce ATA derivation for pda_ata to prevent spoofed token accounts.

Anchor can verify the ATA at account-validation time.

Apply this diff and add the Associated Token Program to the context:

-    #[account(mut)]
-    pub pda_ata: Account<'info, TokenAccount>,
+    #[account(
+        mut,
+        associated_token::mint = mint_account,
+        associated_token::authority = pda
+    )]
+    pub pda_ata: Account<'info, TokenAccount>,
@@
-    /// Token program for NFT operations
-    pub token_program: Program<'info, Token>,
+    /// Token program for NFT operations
+    pub token_program: Program<'info, Token>,
+
+    /// Associated Token program (for ATA checks/creation)
+    pub associated_token_program: Program<'info, anchor_spl::associated_token::AssociatedToken>,

At the top, ensure the import exists:

-use anchor_spl::token::{Token, TokenAccount, Mint};
+use anchor_spl::token::{Token, TokenAccount, Mint};
+use anchor_spl::associated_token::AssociatedToken;
🤖 Prompt for AI Agents
In contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs
around lines 20 to 24, the pda_ata field must be validated as the ATA derived
for (mint, config authority) and the Associated Token Program must be added to
the context and imports; update the account attribute to use seeds and
constraint that verifies the PDA ATA derivation (e.g. with
associated_token::get_associated_token_address equivalent) so the account is
checked at validation time, add a new pub associated_token_program:
Program<'info, AssociatedToken> to the struct, and add the AssociatedToken
import at the top of the file (use anchor_spl::associated_token::AssociatedToken
or equivalent).

Comment on lines +66 to +74
/// System program for account creation
pub system_program: Program<'info, System>,

/// Token program for NFT operations
pub token_program: Program<'info, Token>,

/// Rent sysvar
pub rent: Sysvar<'info, Rent>,
} No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add the Instructions sysvar; without it, on_call cannot authenticate Gateway CPI.

Right now anyone can directly invoke on_call and pass a valid gateway_pda account. You need the instructions sysvar to verify the previous instruction’s program_id equals pda.gateway_program. Add it to the context and use it in the handler.

Apply this diff:

@@
-    /// System program for account creation
+    /// Sysvar: Instructions — required to verify CPI caller (Gateway)
+    /// CHECK: Must be the well-known sysvar address
+    #[account(address = anchor_lang::solana_program::sysvar::instructions::ID)]
+    pub ix_sysvar: UncheckedAccount<'info>,
+
+    /// System program for account creation
     pub system_program: Program<'info, System>,
@@
-    /// Token program for NFT operations
+    /// Token program for NFT operations
     pub token_program: Program<'info, Token>,

In the handler (outside this file), enforce:

use anchor_lang::solana_program::sysvar::instructions as ix_sysvar;

let ix_ai = &ctx.accounts.ix_sysvar;
let idx = ix_sysvar::load_current_index_checked(ix_ai)?;
let prev = ix_sysvar::load_instruction_at_checked((idx.saturating_sub(1)) as usize, ix_ai)?;
require!(prev.program_id == ctx.accounts.pda.gateway_program, UniversalNftError::InvalidGatewayProgram);
require!(prev.accounts.iter().any(|m| m.pubkey == ctx.accounts.gateway_pda.key()), UniversalNftError::InvalidGatewayProgram);

Run to confirm the handler actually performs this check:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -nP -C3 --type=rust 'on_call\s*\(' contracts/nft/contracts/solana/programs/universal-nft
rg -nP -C3 --type=rust '(sysvar::instructions|load_current_index|load_instruction_at)' contracts/nft/contracts/solana/programs/universal-nft

Length of output: 2605


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Print context around the on_call handler to verify gateway CPI checks
sed -n '450,550p' contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs

Length of output: 4565


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and display the OnCall accounts struct to check for the instructions sysvar field
rg -nP -C5 'pub struct OnCall' --type=rust contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs || true

# Print the first 200 lines of the file for context
head -n 200 contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs

Length of output: 3211


Enforce the Instructions Sysvar in the OnCall Context

To guarantee at‐compile time that the instruction sysvar is supplied—and prevent any invocation of on_call without proper Gateway CPI authentication—add the sysvar to your OnCall accounts struct and update the handler to use it directly.

• In contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs, insert before system_program:

@@ pub struct OnCall<'info> {
     /// Gateway PDA, used for minimal caller verification
     #[account(
         constraint = gateway_pda.key() == pda.gateway_pda @ crate::error::UniversalNftError::InvalidGatewayProgram,
         constraint = *gateway_pda.owner == pda.gateway_program @ crate::error::UniversalNftError::InvalidGatewayProgram
     )]
     pub gateway_pda: UncheckedAccount<'info>,

+    /// Sysvar: Instructions — required to verify Gateway CPI
+    /// CHECK: must be the well-known sysvar instructions address
+    #[account(address = anchor_lang::solana_program::sysvar::instructions::ID)]
+    pub ix_sysvar: UncheckedAccount<'info>,

     /// System program for account creation
     pub system_program: Program<'info, System>,

• In the on_call handler (e.g. in src/lib.rs), replace the remaining_accounts lookup with:

let ix_account_info = ctx.accounts.ix_sysvar.to_account_info();
let current_ix = anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(
    -1,
    &ix_account_info,
).map_err(|_| UniversalNftError::InvalidCaller)?;

require!(
    current_ix.program_id == ctx.accounts.pda.gateway_program,
    UniversalNftError::InvalidCaller
);

This change ensures Anchor enforces the presence and correctness of the instructions sysvar at deployment time—eliminating reliance on remaining_accounts scans and avoiding silent misconfiguration.

🤖 Prompt for AI Agents
In contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs
around lines 66–74, add an Instructions sysvar field to the OnCall accounts
struct (insert a field like ix_sysvar: Sysvar<'info, Instructions'> immediately
before system_program) so Anchor enforces the presence of the instructions
sysvar; then in the on_call handler (e.g. src/lib.rs) remove the
remaining_accounts lookup and instead call
ctx.accounts.ix_sysvar.to_account_info(), pass that to
anchor_lang::solana_program::sysvar::instructions::get_instruction_relative(-1,
&ix_account_info) mapping errors to UniversalNftError::InvalidCaller, and
require that the returned instruction's program_id equals
ctx.accounts.pda.gateway_program; also add the necessary import for
get_instruction_relative and ensure UniversalNftError::InvalidCaller is used for
failures.

Comment on lines +28 to +31
echo "🚀 Deploying Universal NFT on ZetaChain testnet (nft:deploy)..."
UNIVERSAL=$(npx hardhat nft:deploy --name ZetaChainUniversalNFT --network zeta_testnet --gateway 0x6c533f7fe93fae114d0954697069df33c9b74fd7 --uniswap-router 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe --json | jq -r '.contractAddress')
echo "ZetaChain Universal NFT: $UNIVERSAL"

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Fix flag name and harden ZetaChain deploy: add retry/--no-install and validate address.

Flag likely should be --uniswapRouter (camelCase), not --uniswap-router. Add retry, avoid auto-install, and fail if jq returns null/invalid.

 echo "🚀 Deploying Universal NFT on ZetaChain testnet (nft:deploy)..."
-UNIVERSAL=$(npx hardhat nft:deploy --name ZetaChainUniversalNFT --network zeta_testnet --gateway 0x6c533f7fe93fae114d0954697069df33c9b74fd7 --uniswap-router 0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe --json | jq -r '.contractAddress')
+UNIVERSAL=$(
+  retry 3 npx --no-install hardhat nft:deploy \
+    --name ZetaChainUniversalNFT \
+    --network zeta_testnet \
+    --gateway "${GATEWAY_ZETA:-0x6c533f7fe93fae114d0954697069df33c9b74fd7}" \
+    --uniswapRouter "${UNISWAP_ROUTER_ZETA:-0x2ca7d64A7EFE2D62A725E2B35Cf7230D6677FfEe}" \
+    --gasLimit "$GAS_LIMIT" \
+    --json | jq -r '.contractAddress'
+)
+if [[ -z "${UNIVERSAL:-}" || "${UNIVERSAL}" == "null" || ! "${UNIVERSAL}" =~ ^0x[0-9a-fA-F]{40}$ ]]; then
+  echo "Failed to obtain Universal NFT address on zeta_testnet. Got: '${UNIVERSAL}'" >&2
+  exit 1
+fi
 echo "ZetaChain Universal NFT: $UNIVERSAL"

Additional helper functions to add once (place near the retry helper):

# helpers
is_eth_address() { [[ "$1" =~ ^0x[0-9a-fA-F]{40}$ ]]; }
fail() { echo "error: $*" >&2; exit 1; }
🤖 Prompt for AI Agents
In contracts/nft/scripts/testnet_nft.sh around lines 28-31, the deploy command
uses the wrong flag name and is brittle; update the hardhat invocation to use
the camelCase flag (--uniswapRouter) and add --no-install to avoid
auto-installing, wrap the npx hardhat call with the existing retry helper so it
retries on transient failures, capture the JSON output then validate the address
returned by jq with an is_eth_address helper and call fail if it's null/invalid;
also add the two helper functions once near the retry helper: is_eth_address
(regex check for 0x + 40 hex chars) and fail (prints to stderr and exits
non-zero).

Comment on lines +40 to +41
CONNECTED_SOLANA="0x48727a61636b6973564d4c436a774a6e7531364b766b6b4843735038726964735a36755176667a697952736e" # TODO: SET HEX OF SOLANA_PROGRAM_ID (e.g: HrzackisVMLCjwJnu16KvkkHCsP8ridsZ6uQvfziyRsn)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate Solana program-id hex and allow override.

Guard against placeholder/typos; fail fast if malformed.

-CONNECTED_SOLANA="0x48727a61636b6973564d4c436a774a6e7531364b766b6b4843735038726964735a36755176667a697952736e" # TODO: SET HEX OF SOLANA_PROGRAM_ID (e.g: HrzackisVMLCjwJnu16KvkkHCsP8ridsZ6uQvfziyRsn)
+CONNECTED_SOLANA="${CONNECTED_SOLANA:-0x48727a61636b6973564d4c436a774a6e7531364b766b6b4843735038726964735a36755176667a697952736e}" # hex(Solana program id)
+[[ "${CONNECTED_SOLANA}" =~ ^0x[0-9a-fA-F]+$ ]] || fail "CONNECTED_SOLANA must be 0x-hex"

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In contracts/nft/scripts/testnet_nft.sh around lines 40-41, the hardcoded
CONNECTED_SOLANA hex may be a placeholder or malformed; add validation and an
override: read an env var (e.g. CONNECTED_SOLANA_HEX) or CLI arg first, then
validate the chosen value starts with "0x" followed by exactly 64 hex characters
(0-9a-f/A-F); if validation fails, print a clear error and exit non-zero
(fail-fast). Ensure the script uses the validated value thereafter and include a
fallback message instructing how to set the env var or pass the argument.

Comment on lines +71 to +82
BASE_XFER_JSON=$(npx hardhat nft:transfer \
--network base_sepolia \
--contract "$CONNECTED_BASE" \
--token-id "$BASE_TOKEN_ID" \
--destination "$ZRC20_SOL" \
--gas-amount 0.005 \
--json)
echo "$BASE_XFER_JSON" | jq -r '. | "🚀 Successfully transferred NFT to the contract.\n📜 Contract address: \(.contractAddress)\n🖼 NFT Contract address: \(.contractAddress)\n🆔 Token ID: \(.tokenId)\n🔗 Transaction hash: \(.transferTransactionHash)\n⛽ Gas used: \(.gasUsed)"'
BASE_TX_HASH=$(echo "$BASE_XFER_JSON" | jq -r '.transferTransactionHash')
echo "🔎 Inbound CCTX: https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/$BASE_TX_HASH"
echo "🔎 Base Sepolia tx: https://sepolia.basescan.org/tx/$BASE_TX_HASH"

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Base→Solana transfer: add retry and validate tx hash; reduce noisy output duplication.

Treat network flakiness and ensure non-null hash before printing links.

-BASE_XFER_JSON=$(npx hardhat nft:transfer \
+BASE_XFER_JSON=$(retry 3 npx --no-install hardhat nft:transfer \
 	--network base_sepolia \
 	--contract "$CONNECTED_BASE" \
 	--token-id "$BASE_TOKEN_ID" \
 	--destination "$ZRC20_SOL" \
 	--gas-amount 0.005 \
 	--json)
-echo "$BASE_XFER_JSON" | jq -r '. | "🚀 Successfully transferred NFT to the contract.\n📜 Contract address: \(.contractAddress)\n🖼 NFT Contract address: \(.contractAddress)\n🆔 Token ID: \(.tokenId)\n🔗 Transaction hash: \(.transferTransactionHash)\n⛽ Gas used: \(.gasUsed)"'
+echo "$BASE_XFER_JSON" | jq -r '. | "🚀 Transfer initiated.\n📜 Contract: \(.contractAddress)\n🆔 Token ID: \(.tokenId)\n🔗 Tx: \(.transferTransactionHash)\n⛽ Gas used: \(.gasUsed)"'
 BASE_TX_HASH=$(echo "$BASE_XFER_JSON" | jq -r '.transferTransactionHash')
+[[ -n "${BASE_TX_HASH}" && "${BASE_TX_HASH}" != "null" ]] || fail "Missing Base transfer tx hash: ${BASE_XFER_JSON}"
 echo "🔎 Inbound CCTX: https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/$BASE_TX_HASH"
 echo "🔎 Base Sepolia tx: https://sepolia.basescan.org/tx/$BASE_TX_HASH"

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +85 to +96
BNB_XFER_JSON=$(npx hardhat nft:transfer \
--network bsc_testnet \
--contract "$CONNECTED_BNB" \
--token-id "$BNB_TOKEN_ID" \
--destination "$ZRC20_SOL" \
--gas-amount 0.005 \
--json)
echo "$BNB_XFER_JSON" | jq -r '. | "🚀 Successfully transferred NFT to the contract.\n📜 Contract address: \(.contractAddress)\n🖼 NFT Contract address: \(.contractAddress)\n🆔 Token ID: \(.tokenId)\n🔗 Transaction hash: \(.transferTransactionHash)\n⛽ Gas used: \(.gasUsed)"'
BNB_TX_HASH=$(echo "$BNB_XFER_JSON" | jq -r '.transferTransactionHash')
echo "🔎 Inbound CCTX: https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/$BNB_TX_HASH"
echo "🔎 BNB Testnet tx: https://testnet.bscscan.com/tx/$BNB_TX_HASH"

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

BNB→Solana transfer: same retry/hash validation as above.

Mirror robustness.

-BNB_XFER_JSON=$(npx hardhat nft:transfer \
+BNB_XFER_JSON=$(retry 3 npx --no-install hardhat nft:transfer \
 	--network bsc_testnet \
 	--contract "$CONNECTED_BNB" \
 	--token-id "$BNB_TOKEN_ID" \
 	--destination "$ZRC20_SOL" \
 	--gas-amount 0.005 \
 	--json)
 echo "$BNB_XFER_JSON" | jq -r '. | "🚀 Successfully transferred NFT to the contract.\n📜 Contract address: \(.contractAddress)\n🖼 NFT Contract address: \(.contractAddress)\n🆔 Token ID: \(.tokenId)\n🔗 Transaction hash: \(.transferTransactionHash)\n⛽ Gas used: \(.gasUsed)"'
 BNB_TX_HASH=$(echo "$BNB_XFER_JSON" | jq -r '.transferTransactionHash')
+[[ -n "${BNB_TX_HASH}" && "${BNB_TX_HASH}" != "null" ]] || fail "Missing BNB transfer tx hash: ${BNB_XFER_JSON}"
 echo "🔎 Inbound CCTX: https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/$BNB_TX_HASH"
 echo "🔎 BNB Testnet tx: https://testnet.bscscan.com/tx/$BNB_TX_HASH"

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In contracts/nft/scripts/testnet_nft.sh around lines 85 to 96, this BNB→Solana
transfer block is missing the same retry/poll and hash-validation logic used
elsewhere; add the same robust retry loop that polls the inbound CCTX endpoint
until the cross-chain inbound record appears, validate the returned
transferTransactionHash (and relevant fields like contractAddress/tokenId)
matches $BNB_TX_HASH (or fail after a timeout/retry limit), surface errors and
non-zero exit on failure, and log progress/status exactly like the other
transfer paths so transfers are retried and verified consistently.

@alperimo
Copy link
Author

alperimo commented Aug 27, 2025

Before we can proceed to reviewing the code, we need the following:

  • addresses of Universal NFT contracts deployed on Base Sepolia, ZetaChain, Solana devnet.
  • transaction hash of a cross-chain NFT transfer from Solana devnet to Base Sepolia
  • transaction hash on a cross-chain NFT transfer from Base Sepolia to Solana devnet
  • transaction hash on a cross-chain NFT transfer from ZetaChain testnet to Solana devnet

@fadeev
well, i still have some questions to solve handling incoming nft transfer to solana from other chains.

https://github.com/zeta-chain/standard-contracts/blob/7835c76675e5e80e1e9aa3c803ae510c772a2013/contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs

as you can see, my on_call must receive nft_origin, metadata, and master_edition as accounts. I need them because i must verify/initialize the origin record and create/update Metaplex metadata and master edition PDAs during the mint/assign step. The question is: How to include them directly in my on_call context.

Should i change the relayer so that it includes them as remaining accounts when calling the gateway execute or should i move the nft creation to the another/second instruction like claim/complete_mint where a client passes those accounts explicitly?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs (1)

102-108: Gate Gateway Program ID by Cargo feature flags.

Prevents accidental deployment with devnet ID on mainnet.

-/// ZetaChain Gateway Program ID (placeholder - replace with actual deployed gateway)
-/// Note: This should be updated with the real ZetaChain gateway program ID for production
-/// For devnet, use: "94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d"
-/// For mainnet, use: "ZETAjseVjuFsxdRxo6MmTCvqFwb3ZHUx56Co3vCmGis"
-
-pub const ZETACHAIN_GATEWAY_PROGRAM_ID: Pubkey = pubkey!("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d");
+/// ZetaChain Gateway Program ID (selected by Cargo feature)
+#[cfg(feature = "mainnet")]
+pub const ZETACHAIN_GATEWAY_PROGRAM_ID: Pubkey = pubkey!("ZETAjseVjuFsxdRxo6MmTCvqFwb3ZHUx56Co3vCmGis");
+#[cfg(not(feature = "mainnet"))]
+pub const ZETACHAIN_GATEWAY_PROGRAM_ID: Pubkey = pubkey!("94U5AHQMKkV5txNJ17QPXWoh474PheGou6cNP2FEuL1d");
🧹 Nitpick comments (8)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (5)

15-16: Make RPC endpoint configurable via env, keep devnet as fallback.

Improves portability and avoids editing code per environment.

-const SOLANA_DEVNET_RPC = "https://api.devnet.solana.com";
+const SOLANA_RPC = process.env.SOLANA_RPC_URL || "https://api.devnet.solana.com";
@@
-    this.connection = new Connection(SOLANA_DEVNET_RPC, "confirmed");
+    this.connection = new Connection(SOLANA_RPC, "confirmed");

Also applies to: 35-39


18-28: Drop unused helpers or wire them in.

toU64LE and sha256 are currently unused.


255-262: Gate verbose IDL diagnostics behind a DEBUG flag.

Prevents noisy logs in normal runs.

-    const idlAddr = (idl as any)?.address;
-    if (idlAddr) {
-      const idlInstr = (idl as any)?.instructions?.find((i: any) => i.name === 'transfer_to_zetachain' || i.name === 'transferToZetachain');
-      console.log('IDL program address:', idlAddr);
-      console.log('Loaded program address:', this.program.programId.toBase58());
-      if (idlInstr) console.log('IDL transferToZetachain args:', idlInstr.args?.map((a: any) => `${a.name}:${JSON.stringify(a.type)}`));
-    }
+    if (process.env.DEBUG_CLI) {
+      const idlAddr = (idl as any)?.address;
+      if (idlAddr) {
+        const idlInstr = (idl as any)?.instructions?.find((i: any) => i.name === 'transfer_to_zetachain' || i.name === 'transferToZetachain');
+        console.log('IDL program address:', idlAddr);
+        console.log('Loaded program address:', this.program.programId.toBase58());
+        if (idlInstr) console.log('IDL transferToZetachain args:', idlInstr.args?.map((a: any) => `${a.name}:${JSON.stringify(a.type)}`));
+      }
+    }

292-296: Add a small CU price to improve inclusion under load.

Keeps CU limit plus priority fee; harmless on low congestion.

 .preInstructions([
   // Give headroom for burn + gateway CPI
   ComputeBudgetProgram.setComputeUnitLimit({ units: 600_000 }),
+  // Optional: priority fee (micro-lamports per CU)
+  ComputeBudgetProgram.setComputeUnitPrice({ microLamports: Number(process.env.CU_PRICE_MICROLAMPORTS ?? 0) }),
 ])

405-406: Clarify usage: finalRecipient is an EVM address.

Avoids confusion about expected format.

-  console.log("Commands:\n  initialize <gatewayProgramPubkey> <gatewayPdaPubkey>\n  update-config <newGatewayProgramPubkey|- or empty> <newGatewayPdaPubkey|- or empty> [newAuthorityPubkey|-] [pause true|false|-]\n  mint <uri> [name] [symbol]\n  transfer <tokenIdHex32> <zcUniversalContractHex20> <destZrc20Hex20|'0'> <finalRecipient> [depositSol default 0.02]\n  status\n  origin <tokenIdHex32>\n  balance");
+  console.log("Commands:\n  initialize <gatewayProgramPubkey> <gatewayPdaPubkey>\n  update-config <newGatewayProgramPubkey|- or empty> <newGatewayPdaPubkey|- or empty> [newAuthorityPubkey|-] [pause true|false|-]\n  mint <uri> [name] [symbol]\n  transfer <tokenIdHex32> <zcUniversalContractHex20> <destZrc20Hex20|'0'> <finalRecipientEvmHex20> [depositSol default 0.02]\n  status\n  origin <tokenIdHex32>\n  balance");
contracts/nft/contracts/solana/programs/universal-nft/src/context/transfer_to_zetachain.rs (3)

6-6: Remove unused import TOKEN_METADATA_PROGRAM_ID.

Avoids warnings; not used in this context.

-use crate::util::constants::{*, TOKEN_METADATA_PROGRAM_ID};
+use crate::util::constants::*;

31-37: ATA constraint set is good; consider also gating delegated balance (optional).

If delegations are possible, you may additionally reject delegated transfers.

Example (if using Anchor >=0.29 supports the field access in constraints):

     #[account(
         mut,
         associated_token::mint = mint,
         associated_token::authority = owner,
-        constraint = token_account.amount == 1 @ crate::error::UniversalNftError::InvalidTokenAmount
+        constraint = token_account.amount == 1 @ crate::error::UniversalNftError::InvalidTokenAmount,
+        constraint = token_account.delegated_amount == 0 @ crate::error::UniversalNftError::InvalidTokenAccount
     )]

8-10: on_call account sourcing: favor remaining_accounts from relayer.

Keep on-chain idempotent but avoid client second step by letting relayer supply nft_origin, metadata, master_edition as remaining accounts (derive/verify seeds on-chain).

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7835c76 and 61fca24.

📒 Files selected for processing (5)
  • contracts/nft/contracts/example/SolanaUniversalNFT.ts (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/context/transfer_to_zetachain.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/error/mod.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs (1 hunks)
  • contracts/nft/contracts/solana/programs/universal-nft/src/util/constants.rs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • contracts/nft/contracts/solana/programs/universal-nft/src/error/mod.rs
  • contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs:7-0
Timestamp: 2025-08-20T06:48:48.341Z
Learning: In Anchor Solana programs, the #[account] macro automatically implements AnchorSerialize, AnchorDeserialize, AccountSerialize, AccountDeserialize, Discriminator, Owner, and Clone traits for structs. Explicit derives for serialization are not needed and would be redundant for account data structures marked with #[account].
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/lib.rs:178-194
Timestamp: 2025-08-22T12:08:58.842Z
Learning: In the Solana universal-nft program, NFT_ORIGIN_SPACE constant already includes the 8-byte Anchor discriminator in its calculation (8 + 32 + 32 + 32 + 4 + MAX_URI_LENGTH + 8 + 8 + 1 + 1), and NftOrigin::LEN correctly uses this value for account space allocation.
Learnt from: alperimo
PR: zeta-chain/standard-contracts#93
File: contracts/nft/contracts/solana/programs/universal-nft/src/state/nft_origin.rs:7-0
Timestamp: 2025-08-20T06:48:48.341Z
Learning: In Anchor Solana programs, the #[account] macro automatically implements AnchorSerialize and AnchorDeserialize traits for structs, so explicit derives are not needed for account data structures.
🔇 Additional comments (1)
contracts/nft/contracts/example/SolanaUniversalNFT.ts (1)

59-65: Seed parity confirmed – The TypeScript deriveConfigPda uses "connected", which exactly matches the Rust UNIVERSAL_NFT_CONFIG_SEED: &[u8] = b"connected"; constant, so no changes are needed.

Comment on lines +218 to +237
// Strict arg validations
const tokenId = Buffer.from(tokenIdHex.replace(/^0x/, ''), 'hex');
if (tokenId.length !== 32) throw new Error(`tokenId must be 32 bytes hex (got ${tokenId.length})`);
const receiver = Buffer.from(zcUniversalContractHex.replace(/^0x/, ''), 'hex');
if (receiver.length !== 20) throw new Error(`zetachain universal contract must be 20 bytes hex (got ${receiver.length})`);
// destinationZrc20: accept '0' (stay on ZetaChain) or 20-byte hex address
const destRaw = destinationZrc20HexOrZero.trim();
let destinationZrc20: Buffer;
if (destRaw === '0' || /^0x?0+$/.test(destRaw)) {
destinationZrc20 = Buffer.alloc(20, 0);
} else {
const destBuf = Buffer.from(destRaw.replace(/^0x/, ''), 'hex');
if (destBuf.length !== 20) throw new Error(`destinationZrc20 must be 20 bytes hex or '0' (got ${destBuf.length})`);
destinationZrc20 = destBuf;
}
if (!finalRecipient) throw new Error("finalRecipient must be provided as a 20-byte EVM address (hex)");
const recRaw = finalRecipient.trim();
const recBuf = Buffer.from(recRaw.replace(/^0x/, ''), 'hex');
if (recBuf.length !== 20) throw new Error(`finalRecipient must be a 20-byte hex EVM address (got ${recBuf.length} bytes)`);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Harden hex parsing with length/charset validation.

Prevents odd-length and non-hex inputs from reaching Buffer.from.

-    const tokenId = Buffer.from(tokenIdHex.replace(/^0x/, ''), 'hex');
-    if (tokenId.length !== 32) throw new Error(`tokenId must be 32 bytes hex (got ${tokenId.length})`);
-    const receiver = Buffer.from(zcUniversalContractHex.replace(/^0x/, ''), 'hex');
-    if (receiver.length !== 20) throw new Error(`zetachain universal contract must be 20 bytes hex (got ${receiver.length})`);
+    if (!/^(0x)?[0-9a-fA-F]{64}$/.test(tokenIdHex)) throw new Error("tokenId must be 32-byte hex");
+    const tokenId = Buffer.from(tokenIdHex.replace(/^0x/, ''), 'hex');
+    if (!/^(0x)?[0-9a-fA-F]{40}$/.test(zcUniversalContractHex)) throw new Error("zetachain universal contract must be 20-byte hex");
+    const receiver = Buffer.from(zcUniversalContractHex.replace(/^0x/, ''), 'hex');
@@
-    } else {
-      const destBuf = Buffer.from(destRaw.replace(/^0x/, ''), 'hex');
-      if (destBuf.length !== 20) throw new Error(`destinationZrc20 must be 20 bytes hex or '0' (got ${destBuf.length})`);
-      destinationZrc20 = destBuf;
-    }
-    if (!finalRecipient) throw new Error("finalRecipient must be provided as a 20-byte EVM address (hex)");
-    const recRaw = finalRecipient.trim();
-    const recBuf = Buffer.from(recRaw.replace(/^0x/, ''), 'hex');
-    if (recBuf.length !== 20) throw new Error(`finalRecipient must be a 20-byte hex EVM address (got ${recBuf.length} bytes)`);
+    } else {
+      if (!/^(0x)?[0-9a-fA-F]{40}$/.test(destRaw)) throw new Error("destinationZrc20 must be 20-byte hex or '0'");
+      destinationZrc20 = Buffer.from(destRaw.replace(/^0x/, ''), 'hex');
+    }
+    if (!/^(0x)?[0-9a-fA-F]{40}$/.test(finalRecipient)) throw new Error("finalRecipient must be a 20-byte EVM address (hex)");
+    const recBuf = Buffer.from(finalRecipient.replace(/^0x/, ''), 'hex');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Strict arg validations
const tokenId = Buffer.from(tokenIdHex.replace(/^0x/, ''), 'hex');
if (tokenId.length !== 32) throw new Error(`tokenId must be 32 bytes hex (got ${tokenId.length})`);
const receiver = Buffer.from(zcUniversalContractHex.replace(/^0x/, ''), 'hex');
if (receiver.length !== 20) throw new Error(`zetachain universal contract must be 20 bytes hex (got ${receiver.length})`);
// destinationZrc20: accept '0' (stay on ZetaChain) or 20-byte hex address
const destRaw = destinationZrc20HexOrZero.trim();
let destinationZrc20: Buffer;
if (destRaw === '0' || /^0x?0+$/.test(destRaw)) {
destinationZrc20 = Buffer.alloc(20, 0);
} else {
const destBuf = Buffer.from(destRaw.replace(/^0x/, ''), 'hex');
if (destBuf.length !== 20) throw new Error(`destinationZrc20 must be 20 bytes hex or '0' (got ${destBuf.length})`);
destinationZrc20 = destBuf;
}
if (!finalRecipient) throw new Error("finalRecipient must be provided as a 20-byte EVM address (hex)");
const recRaw = finalRecipient.trim();
const recBuf = Buffer.from(recRaw.replace(/^0x/, ''), 'hex');
if (recBuf.length !== 20) throw new Error(`finalRecipient must be a 20-byte hex EVM address (got ${recBuf.length} bytes)`);
// Strict arg validations
if (!/^(0x)?[0-9a-fA-F]{64}$/.test(tokenIdHex)) throw new Error("tokenId must be 32-byte hex");
const tokenId = Buffer.from(tokenIdHex.replace(/^0x/, ''), 'hex');
if (!/^(0x)?[0-9a-fA-F]{40}$/.test(zcUniversalContractHex)) throw new Error("zetachain universal contract must be 20-byte hex");
const receiver = Buffer.from(zcUniversalContractHex.replace(/^0x/, ''), 'hex');
// destinationZrc20: accept '0' (stay on ZetaChain) or 20-byte hex address
const destRaw = destinationZrc20HexOrZero.trim();
let destinationZrc20: Buffer;
if (destRaw === '0' || /^0x?0+$/.test(destRaw)) {
destinationZrc20 = Buffer.alloc(20, 0);
} else {
if (!/^(0x)?[0-9a-fA-F]{40}$/.test(destRaw)) throw new Error("destinationZrc20 must be 20-byte hex or '0'");
destinationZrc20 = Buffer.from(destRaw.replace(/^0x/, ''), 'hex');
}
if (!/^(0x)?[0-9a-fA-F]{40}$/.test(finalRecipient)) throw new Error("finalRecipient must be a 20-byte EVM address (hex)");
const recBuf = Buffer.from(finalRecipient.replace(/^0x/, ''), 'hex');
🤖 Prompt for AI Agents
In contracts/nft/contracts/example/SolanaUniversalNFT.ts around lines 218 to
237, the current code calls Buffer.from on input strings without validating that
they are proper even-length hex and contain only hex characters; add explicit
validation before calling Buffer.from for tokenIdHex, zcUniversalContractHex,
destinationZrc20HexOrZero (when not '0'), and finalRecipient: strip an optional
0x prefix, ensure the remaining string length is even and matches
/^[0-9a-fA-F]+$/ (for destination allow a single '0' or all-zero pattern handled
already), and throw clear errors indicating invalid charset or odd length; only
then convert to Buffer and continue with the existing length checks and error
messages.

@Ashutosh0x

This comment was marked as spam.

@Ashutosh0x

This comment was marked as spam.

@fadeev
Copy link
Member

fadeev commented Aug 29, 2025

@alperimo

Should i change the relayer so that it includes them as remaining accounts when calling the gateway execute

Yes, it's completely fine to change the relayer (I think you mean ZetaChain Universal NFT) as long as it keeps working with EVM chains.

@kingpinXD wdyt?

@kingpinXD
Copy link

https://github.com/zeta-chain/standard-contracts/blob/7835c76675e5e80e1e9aa3c803ae510c772a2013/contracts/nft/contracts/solana/programs/universal-nft/src/context/on_call.rs

Either of the two options is fine
Passing them via the remaining accounts might be easier.

What do you mean by modifying the relayer? The on_call function is called by the gateway program on solana, which adds remaining_accounts
Here is the link for reference
https://github.com/zeta-chain/protocol-contracts-solana/blob/f5d96872749864e4ae98241455cd1336f0fae90d/programs/gateway/src/instructions/execute.rs#L37-L90

// 3) Mint 1 to program_ata using config PDA as authority
mint_nft_to_recipient(
&ctx.accounts.mint_account,
&ctx.accounts.pda_ata,
Copy link

@kingpinXD kingpinXD Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this get transferred to the user who initiated the call

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess its wrong. i have to change this. i assume the recipient address will be in data payload so i could get recipient ata from the recipient wallet address and mint, then transfer the nft in pda_ata to that ata.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2d75c2e

i pushed the changes and now used token::transfer_checked to transfer nft from pda_ata to recipient_ata. but i added recipient and recipient_ata accounts into the OnCall context instead of getting them through ctx.remaining_accounts bc i had an annoying lifetime issue during the build.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the issue you were facing ?
Its not super clear to me what you mean by not using remianing accounts,
the account_metas for the on_call is created like so

https://github.com/zeta-chain/protocol-contracts-solana/blob/f5d96872749864e4ae98241455cd1336f0fae90d/programs/gateway/src/utils/prepare_account_metas.rs#L8-L32

@ExpertVagabond
Copy link

Please add the following to your PR:

Contract addresses on Base Sepolia, ZetaChain, Solana Devnet

Tx hash: Solana → Base

Tx hash: Base → Solana

Tx hash: ZetaChain → Solana

These are required before we proceed with review.

@alperimo
Copy link
Author

alperimo commented Sep 5, 2025

@kingpinXD @fadeev

idk because i spammed the transfer too much but i encountered this error several times during an NFT transfer from Base Sepolia to Solana.

Base Sepolia -> Zetachain: https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0x1fa2bb603780ab9965e881de8268f3c1403d46416119c36468df2358e07318c6

Zetachain -> Solana: https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0xa2b2096c5461930e45e8506913b7d69db62c28890f1a300f219b10755be51bdb

https://explorer.solana.com/tx/3UdQZdbTBiq9LAcHr5DHs18Y5M15K6F5ntWj41YAdbEXGa3X6gmsq3KdB9mi2YMsHs78ut69VGhh5DYLtnRxjRu?cluster=devnet

i even tried with an empty/example (one of the zetachain solana examples on github) on_call, always got tx outbound failed on the connected chain error.

after a while, it started failing on the Zetachain side too xd.

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0x0d9de95e046b8e000008804361c2b5dbca3d6d7d64817e7a3962b4ac067dbb20

to keep here, My contract addresses:
on Zetachain testnet: 0x5AE1702fBF1Db5E7238dC8De0dc28e46C3Dbd36A
on Base sepolia: 0xc8E30750e0331E3dd20049922D03810E45C839e3
on Solana devnet: HrzackisVMLCjwJnu16KvkkHCsP8ridsZ6uQvfziyRsn

@kingpinXD
Copy link

https://explorer.solana.com/tx/3UdQZdbTBiq9LAcHr5DHs18Y5M15K6F5ntWj41YAdbEXGa3X6gmsq3KdB9mi2YMsHs78ut69VGhh5DYLtnRxjRu?cluster=devnet

Base Sepolia -> Zetachain: This transaction seems okay

Zetachain -> Solana: This tx reverted from the solana program, and went back to Zetachain. The revert was however, successful.

https://zetachain-athens.blockpi.network/lcd/v1/public/zeta-chain/crosschain/inboundHashToCctxData/0x0d9de95e046b8e000008804361c2b5dbca3d6d7d64817e7a3962b4ac067dbb20

Is this tx the same as the first one ? Were you able to use the revert reason to figure out the reason ?

@ExpertVagabond
Copy link

Please check your messages we are ready to announce a winner from the Superteam Earn bounty.

@alperimo
Copy link
Author

@ExpertVagabond @kingpinXD

Hey thanks for the notice, i was logged out somehow and couldnt see the announcement. It was a pleasure for me to work on it. I couldnt make the nft transfer to work from other chains to Solana properly though. The problem was on my solana side and while i was about to debug it, i faced with another issue that i mentioned which was quite annoying for me. Sometimes increasing the gas amount to 0.02 - 0.03 during the nft transfer from base sepolia to solana was solving that though. The solution for my problem is probably quite easy but couldnt make it so far. I would be happy if you have time for a quick meet/zoom call in this week so that i can show my current status. Finally i would like to make this program working completely.

Best Regards

Please check your messages we are ready to announce a winner from the Superteam Earn bounty.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants