diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..eb5a316cb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target diff --git a/umbra-helix/.gitignore b/umbra-helix/.gitignore new file mode 100644 index 000000000..ab48a797c --- /dev/null +++ b/umbra-helix/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +node_modules +.cert +.env +.env.local diff --git a/umbra-helix/.yarn/install-state.gz b/umbra-helix/.yarn/install-state.gz new file mode 100644 index 000000000..de62249d4 Binary files /dev/null and b/umbra-helix/.yarn/install-state.gz differ diff --git a/umbra-helix/README.md b/umbra-helix/README.md new file mode 100644 index 000000000..c8ae41c4a --- /dev/null +++ b/umbra-helix/README.md @@ -0,0 +1,74 @@ +# UmbraHelix + +UmbraHelix is an Aztec NFT Ownership verifier for Discord. It also has other useful features like creating collection, minting nft, public and private transfer and to fetch nft owner. + +Developers can use UmbraHelix by installing the sdk from npm. + +## Challenge Selection +- [x] Social Cipher +- [ ] ZKEmail Guardian + +## Team information +- **Satyam Bansal** - Full stack developer and open source contributor, Kernel community fellow + - GitHub: https://github.com/satyambnsal + - Twitter: @satyambnsal + - Email: satyamsgsits1994@gmail.com + +- **Yash Mittal** - UI developer + - GitHub: https://github.com/yassmittal + +## Video Demo +https://www.loom.com/share/22de26bc290a4326bac074992e3338c9?sid=064cd65f-12c3-4270-a2ff-3e3eaf99496a + +## Technical Approach + +### Main Components: +1. **NFT Smart Contract** +- Custom NFT contract written in Noir enabling both public and private transfers +- Support for collection creation, minting, and ownership verification +- Located in `contracts/nft_contracts` + +2. **Client SDK** +- TypeScript SDK for easy integration +- Support for wallet creation, collection management, and NFT operations +- Privacy-preserving ownership verification functions + +3. **Discord Integration** +- Bot interface for role management based on private NFT ownership verification +- Private membership verification without exposing full ownership details + +### Key Aztec Features Used: +- Private state for NFT ownership records +- Public/private transfer functions +- AuthWit for delegated operations +- PXE integration for client-side proof generation + +## Expected Outcomes +- Easy-to-use SDK for developers +- Working Discord bot demonstrating private ownership verification +- Documentation and examples for building privacy-preserving social applications + +## Lessons Learned (For Submission) +- Implementing privacy-preserving ownership verification requires careful consideration of information leakage +- Pattern for handling both public and private NFT transfers while maintaining privacy +- Best practices for PXE integration and client-side proof generation +- Reusable patterns for Discord bot integration with private state verification + +## Project Links +- GitHub Repository: https://github.com/umbra-privacy/umbra-helix +- Documentation & Examples: [To be added] + + + +### Installation & Usage +```bash +# Clone the repository +git clone https://github.com/umbra-privacy/umbra-helix + +cd umbra-helix + +# Install dependencies +bun install + +# Start development server +bun run dev diff --git a/umbra-helix/bun.lockb b/umbra-helix/bun.lockb new file mode 100755 index 000000000..ff9db3a1a Binary files /dev/null and b/umbra-helix/bun.lockb differ diff --git a/umbra-helix/contracts/nft_contracts/Nargo.toml b/umbra-helix/contracts/nft_contracts/Nargo.toml new file mode 100644 index 000000000..05a1b4dd1 --- /dev/null +++ b/umbra-helix/contracts/nft_contracts/Nargo.toml @@ -0,0 +1,12 @@ +[package] +name = "nft_contract" +type = "contract" +authors = ["Satyam Bansal "] +compiler_version = ">=0.33.0" + +[dependencies] +aztec = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/aztec" } +value_note = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/value-note" } +compressed_string = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/compressed-string" } +authwit = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/authwit" } +easy_private_state = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "aztec-packages-v0.60.0", directory = "noir-projects/aztec-nr/easy-private-state" } diff --git a/umbra-helix/contracts/nft_contracts/codegenCache.json b/umbra-helix/contracts/nft_contracts/codegenCache.json new file mode 100644 index 000000000..e1b415fc0 --- /dev/null +++ b/umbra-helix/contracts/nft_contracts/codegenCache.json @@ -0,0 +1,10 @@ +{ + "nft_contract-Counter.json": { + "contractName": "Counter", + "hash": "774eb477ea6f8b020b11b48c759143a7745f73073628c94f9ded1dfd2f6a24cf" + }, + "nft_contract-NFT.json": { + "contractName": "NFT", + "hash": "267d2623b816deec6174ceb2f5330d9f4ba7353074e8fa03be478616f5730d5c" + } +} \ No newline at end of file diff --git a/umbra-helix/contracts/nft_contracts/src/main.nr b/umbra-helix/contracts/nft_contracts/src/main.nr new file mode 100644 index 000000000..c845d03b3 --- /dev/null +++ b/umbra-helix/contracts/nft_contracts/src/main.nr @@ -0,0 +1,367 @@ +mod types; + +use dep::aztec::macros::aztec; + +// Minimal NFT implementation with `AuthWit` support that allows minting in public-only and transfers in both public +// and private. +#[aztec] +contract NFT { + use dep::compressed_string::FieldCompressedString; + use dep::aztec::{ + oracle::random::random, + prelude::{ + NoteGetterOptions, NoteViewerOptions, Map, PublicMutable, SharedImmutable, PrivateSet, + AztecAddress, PrivateContext, PublicContext, + }, + encrypted_logs::encrypted_note_emission::{ + encode_and_encrypt_note, encrypt_and_emit_partial_log, + }, hash::pedersen_hash, keys::getters::get_public_keys, note::constants::MAX_NOTES_PER_PAGE, + protocol_types::traits::is_empty, utils::comparison::Comparator, + protocol_types::{point::Point, traits::Serialize}, + macros::{ + storage::storage, events::event, + functions::{private, public, view, internal, initializer}, + }, + }; + use dep::authwit::auth::{ + assert_current_call_valid_authwit, assert_current_call_valid_authwit_public, + compute_authwit_nullifier, + }; + use std::{embedded_curve_ops::EmbeddedCurvePoint, meta::derive}; + use crate::types::nft_note::NFTNote; + + // TODO(#8467): Rename this to Transfer - calling this NFTTransfer to avoid export conflict with the Transfer event + // in the Token contract. + #[event] + #[derive(Serialize)] + struct NFTTransfer { + from: AztecAddress, + to: AztecAddress, + token_id: Field, + } + + #[storage] + struct Storage { + // The symbol of the NFT + symbol: SharedImmutable, + // The name of the NFT + name: SharedImmutable, + // The admin of the contract + admin: PublicMutable, + // Addresses that can mint + minters: Map, Context>, + // Contains the NFTs owned by each address in private. + private_nfts: Map, Context>, + // A map from token ID to a boolean indicating if the NFT exists. + nft_exists: Map, Context>, + // A map from token ID to the public owner of the NFT. + public_owners: Map, Context>, + } + + #[public] + #[initializer] + fn constructor(admin: AztecAddress, name: str<31>, symbol: str<31>) { + assert(!admin.is_zero(), "invalid admin"); + storage.admin.write(admin); + storage.minters.at(admin).write(true); + storage.name.initialize(FieldCompressedString::from_string(name)); + storage.symbol.initialize(FieldCompressedString::from_string(symbol)); + } + + #[public] + fn set_admin(new_admin: AztecAddress) { + assert(storage.admin.read().eq(context.msg_sender()), "caller is not an admin"); + storage.admin.write(new_admin); + } + + #[public] + fn set_minter(minter: AztecAddress, approve: bool) { + assert(storage.admin.read().eq(context.msg_sender()), "caller is not an admin"); + storage.minters.at(minter).write(approve); + } + + #[public] + fn mint(to: AztecAddress, token_id: Field) { + assert(token_id != 0, "zero token ID not supported"); + assert(storage.minters.at(context.msg_sender()).read(), "caller is not a minter"); + assert(storage.nft_exists.at(token_id).read() == false, "token already exists"); + + storage.nft_exists.at(token_id).write(true); + + storage.public_owners.at(token_id).write(to); + } + + #[public] + #[view] + fn public_get_name() -> pub FieldCompressedString { + storage.name.read_public() + } + + #[private] + #[view] + fn private_get_name() -> pub FieldCompressedString { + storage.name.read_private() + } + + #[public] + #[view] + fn public_get_symbol() -> pub FieldCompressedString { + storage.symbol.read_public() + } + + #[private] + #[view] + fn private_get_symbol() -> pub FieldCompressedString { + storage.symbol.read_private() + } + + #[public] + #[view] + fn get_admin() -> Field { + storage.admin.read().to_field() + } + + #[public] + #[view] + fn is_minter(minter: AztecAddress) -> bool { + storage.minters.at(minter).read() + } + + #[public] + fn transfer_in_public(from: AztecAddress, to: AztecAddress, token_id: Field, nonce: Field) { + if (!from.eq(context.msg_sender())) { + assert_current_call_valid_authwit_public(&mut context, from); + } else { + assert(nonce == 0, "invalid nonce"); + } + + let public_owners_storage = storage.public_owners.at(token_id); + assert(public_owners_storage.read().eq(from), "invalid owner"); + + public_owners_storage.write(to); + } + + // Transfers token with `token_id` from public balance of message sender to a private balance of `to`. + #[private] + fn transfer_to_private(to: AztecAddress, token_id: Field) { + let from = context.msg_sender(); + + let nft = NFT::at(context.this_address()); + + // We prepare the transfer. + let hiding_point_slot = _prepare_transfer_to_private(to, &mut context, storage); + + // At last we finalize the transfer. Usafe of the `unsafe` method here is safe because we set the `from` + // function argument to a message sender, guaranteeing that he can transfer only his own NFTs. + nft._finalize_transfer_to_private_unsafe(from, token_id, hiding_point_slot).enqueue( + &mut context, + ); + } + + /// Prepares a transfer to a private balance of `to`. The transfer then needs to be + /// finalized by calling `finalize_transfer_to_private`. Returns a hiding point slot. + #[private] + fn prepare_transfer_to_private(to: AztecAddress) -> Field { + _prepare_transfer_to_private(to, &mut context, storage) + } + + /// This function exists separately from `prepare_transfer_to_private` solely as an optimization as it allows + /// us to have it inlined in the `transfer_to_private` function which results in one less kernel iteration. + /// + /// TODO(#9180): Consider adding macro support for functions callable both as an entrypoint and as an internal + /// function. + #[contract_library_method] + fn _prepare_transfer_to_private( + to: AztecAddress, + context: &mut PrivateContext, + storage: Storage<&mut PrivateContext>, + ) -> Field { + let to_keys = get_public_keys(to); + let to_npk_m_hash = to_keys.npk_m.hash(); + let to_note_slot = storage.private_nfts.at(to).storage_slot; + + // We create a setup payload with unpopulated/zero token id for 'to' + // TODO(#7775): Manually fetching the randomness here is not great. If we decide to include randomness in all + // notes we could just inject it in macros. + let note_randomness = unsafe { random() }; + let note_setup_payload = + NFTNote::setup_payload().new(to_npk_m_hash, note_randomness, to_note_slot); + + // We encrypt and emit the partial note log + encrypt_and_emit_partial_log(context, note_setup_payload.log_plaintext, to_keys, to); + + // Using the x-coordinate as a hiding point slot is safe against someone else interfering with it because + // we have a guarantee that the public functions of the transaction are executed right after the private ones + // and for this reason the protocol guarantees that nobody can front-run us in consuming the hiding point. + // This guarantee would break if `finalize_transfer_to_private` was not called in the same transaction. This + // however is not the flow we are currently concerned with. To support the multi-transaction flow we could + // introduce a `from` function argument, hash the x-coordinate with it and then repeat the hashing in + // `finalize_transfer_to_private`. + // + // We can also be sure that the `hiding_point_slot` will not overwrite any other value in the storage because + // in our state variables we derive slots using a different hash function from multi scalar multiplication + // (MSM). + let hiding_point_slot = note_setup_payload.hiding_point.x; + + // We don't need to perform a check that the value overwritten by `_store_point_in_transient_storage_unsafe` + // is zero because the slot is the x-coordinate of the hiding point and hence we could only overwrite + // the value in the slot with the same value. This makes usage of the `unsafe` method safe. + NFT::at(context.this_address()) + ._store_point_in_transient_storage_unsafe( + hiding_point_slot, + note_setup_payload.hiding_point, + ) + .enqueue(context); + + hiding_point_slot + } + + #[public] + #[internal] + fn _store_point_in_transient_storage_unsafe(slot: Field, point: Point) { + context.storage_write(slot, point); + } + + /// Finalizes a transfer of NFT with `token_id` from public balance of `from` to a private balance of `to`. + /// The transfer must be prepared by calling `prepare_transfer_to_private` first and the resulting + /// `hiding_point_slot` must be passed as an argument to this function. + #[public] + fn finalize_transfer_to_private(token_id: Field, hiding_point_slot: Field) { + let from = context.msg_sender(); + _finalize_transfer_to_private(from, token_id, hiding_point_slot, &mut context, storage); + } + + #[public] + #[internal] + fn _finalize_transfer_to_private_unsafe( + from: AztecAddress, + token_id: Field, + hiding_point_slot: Field, + ) { + _finalize_transfer_to_private(from, token_id, hiding_point_slot, &mut context, storage); + } + + #[contract_library_method] + fn _finalize_transfer_to_private( + from: AztecAddress, + token_id: Field, + hiding_point_slot: Field, + context: &mut PublicContext, + storage: Storage<&mut PublicContext>, + ) { + let public_owners_storage = storage.public_owners.at(token_id); + assert(public_owners_storage.read().eq(from), "invalid NFT owner"); + + // Read the hiding point from "transient" storage and check it's not empty to ensure the transfer was prepared + let hiding_point: Point = context.storage_read(hiding_point_slot); + assert(!is_empty(hiding_point), "transfer not prepared"); + + // Set the public NFT owner to zero + public_owners_storage.write(AztecAddress::zero()); + + // Finalize the partial note with the `token_id` + let finalization_payload = NFTNote::finalization_payload().new(hiding_point, token_id); + + // We insert the finalization note hash + context.push_note_hash(finalization_payload.note_hash); + + // We emit the `token_id` as unencrypted event such that the `NoteProcessor` can use it to reconstruct the note + context.emit_unencrypted_log(finalization_payload.log); + + // At last we reset public storage to zero to achieve the effect of transient storage - kernels will squash + // the writes + context.storage_write(hiding_point_slot, Point::empty()); + } + + /** + * Cancel a private authentication witness. + * @param inner_hash The inner hash of the authwit to cancel. + */ + #[private] + fn cancel_authwit(inner_hash: Field) { + let on_behalf_of = context.msg_sender(); + let nullifier = compute_authwit_nullifier(on_behalf_of, inner_hash); + context.push_nullifier(nullifier); + } + + #[private] + fn transfer_in_private(from: AztecAddress, to: AztecAddress, token_id: Field, nonce: Field) { + if (!from.eq(context.msg_sender())) { + assert_current_call_valid_authwit(&mut context, from); + } else { + assert(nonce == 0, "invalid nonce"); + } + + let nfts = storage.private_nfts; + + let notes = nfts.at(from).pop_notes(NoteGetterOptions::new() + .select(NFTNote::properties().token_id, Comparator.EQ, token_id) + .set_limit(1)); + assert(notes.len() == 1, "NFT not found when transferring"); + + let from_ovpk_m = get_public_keys(from).ovpk_m; + let to_keys = get_public_keys(to); + + let mut new_note = NFTNote::new(token_id, to_keys.npk_m.hash()); + nfts.at(to).insert(&mut new_note).emit(encode_and_encrypt_note( + &mut context, + from_ovpk_m, + to_keys.ivpk_m, + to, + )); + } + + #[private] + fn transfer_to_public(from: AztecAddress, to: AztecAddress, token_id: Field, nonce: Field) { + if (!from.eq(context.msg_sender())) { + assert_current_call_valid_authwit(&mut context, from); + } else { + assert(nonce == 0, "invalid nonce"); + } + + let notes = storage.private_nfts.at(from).pop_notes(NoteGetterOptions::new() + .select(NFTNote::properties().token_id, Comparator.EQ, token_id) + .set_limit(1)); + assert(notes.len() == 1, "NFT not found when transferring to public"); + + NFT::at(context.this_address())._finish_transfer_to_public(to, token_id).enqueue( + &mut context, + ); + } + + #[public] + #[internal] + fn _finish_transfer_to_public(to: AztecAddress, token_id: Field) { + storage.public_owners.at(token_id).write(to); + } + + // Returns zero address when the token does not have a public owner. Reverts if the token does not exist. + #[public] + #[view] + fn owner_of(token_id: Field) -> AztecAddress { + assert(storage.nft_exists.at(token_id).read(), "token does not exist"); + storage.public_owners.at(token_id).read() + } + + /// Returns an array of token IDs owned by `owner` in private and a flag indicating whether a page limit was + /// reached. Starts getting the notes from page with index `page_index`. Zero values in the array are placeholder + /// values for non-existing notes. + unconstrained fn get_private_nfts( + owner: AztecAddress, + page_index: u32, + ) -> pub ([Field; MAX_NOTES_PER_PAGE], bool) { + let offset = page_index * MAX_NOTES_PER_PAGE; + let mut options = NoteViewerOptions::new(); + let notes = storage.private_nfts.at(owner).view_notes(options.set_offset(offset)); + + let mut owned_nft_ids = [0; MAX_NOTES_PER_PAGE]; + for i in 0..options.limit { + if i < notes.len() { + owned_nft_ids[i] = notes.get_unchecked(i).token_id; + } + } + + let page_limit_reached = notes.len() == options.limit; + (owned_nft_ids, page_limit_reached) + } +} diff --git a/umbra-helix/contracts/nft_contracts/src/types.nr b/umbra-helix/contracts/nft_contracts/src/types.nr new file mode 100644 index 000000000..26ed97fbe --- /dev/null +++ b/umbra-helix/contracts/nft_contracts/src/types.nr @@ -0,0 +1 @@ +mod nft_note; diff --git a/umbra-helix/contracts/nft_contracts/src/types/nft_note.nr b/umbra-helix/contracts/nft_contracts/src/types/nft_note.nr new file mode 100644 index 000000000..085c16b21 --- /dev/null +++ b/umbra-helix/contracts/nft_contracts/src/types/nft_note.nr @@ -0,0 +1,56 @@ +use dep::aztec::{ + note::utils::compute_note_hash_for_nullify, keys::getters::get_nsk_app, oracle::random::random, + prelude::{NullifiableNote, NoteHeader, PrivateContext}, + protocol_types::{constants::GENERATOR_INDEX__NOTE_NULLIFIER, hash::poseidon2_hash_with_separator, traits::{Empty, Eq}}, + macros::notes::partial_note +}; + +#[partial_note(quote { token_id})] +pub struct NFTNote { + // ID of the token + token_id: Field, + // The nullifying public key hash is used with the nsk_app to ensure that the note can be privately spent. + npk_m_hash: Field, + // Randomness of the note to hide its contents + randomness: Field, +} + +impl NullifiableNote for NFTNote { + fn compute_nullifier(self, context: &mut PrivateContext, note_hash_for_nullify: Field) -> Field { + let secret = context.request_nsk_app(self.npk_m_hash); + poseidon2_hash_with_separator( + [note_hash_for_nullify, secret], + GENERATOR_INDEX__NOTE_NULLIFIER as Field + ) + } + + unconstrained fn compute_nullifier_without_context(self) -> Field { + let note_hash_for_nullify = compute_note_hash_for_nullify(self); + let secret = get_nsk_app(self.npk_m_hash); + poseidon2_hash_with_separator( + [note_hash_for_nullify, secret], + GENERATOR_INDEX__NOTE_NULLIFIER as Field + ) + } +} + +impl NFTNote { + pub fn new(token_id: Field, npk_m_hash: Field) -> Self { + // We use the randomness to preserve the privacy of the note recipient by preventing brute-forcing, so a + // malicious sender could use non-random values to make the note less private. But they already know the full + // note pre-image anyway, and so the recipient already trusts them to not disclose this information. We can + // therefore assume that the sender will cooperate in the random value generation. + let randomness = unsafe { + random() + }; + NFTNote { token_id, npk_m_hash, randomness, header: NoteHeader::empty() } + } +} + +impl Eq for NFTNote { + fn eq(self, other: Self) -> bool { + (self.token_id == other.token_id) + & (self.npk_m_hash == other.npk_m_hash) + & (self.randomness == other.randomness) + } +} diff --git a/umbra-helix/discord-server/commands.js b/umbra-helix/discord-server/commands.js new file mode 100644 index 000000000..ef9c32996 --- /dev/null +++ b/umbra-helix/discord-server/commands.js @@ -0,0 +1,25 @@ +import 'dotenv/config'; +import { InstallGlobalCommands } from './utils.js'; + + +// Simple test command +const VERIFY_COMMAND = { + name: 'verify', + description: 'Basic command', + type: 1, + integration_types: [0, 1], + contexts: [0, 1, 2], +}; + + +const NFTS_COMMAND = { + name: 'nfts', + description: 'View your verified NFTs', + type: 1, + integration_types: [0, 1], + contexts: [0, 1, 2], +}; + +const ALL_COMMANDS = [VERIFY_COMMAND, NFTS_COMMAND]; + +InstallGlobalCommands(process.env.APP_ID, ALL_COMMANDS); diff --git a/umbra-helix/discord-server/server.js b/umbra-helix/discord-server/server.js new file mode 100644 index 000000000..9fe4224bf --- /dev/null +++ b/umbra-helix/discord-server/server.js @@ -0,0 +1,79 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import { + InteractionType, + InteractionResponseType, + verifyKeyMiddleware, +} from 'discord-interactions'; + +const app = express(); +app.use(express.json()); +app.use(cors()); + +app.post('/interactions', verifyKeyMiddleware(process.env.PUBLIC_KEY), async function (req, res) { + const { type, data } = req.body; + + if (type === InteractionType.PING) { + return res.send({ type: InteractionResponseType.PONG }); + } + + if (type === InteractionType.APPLICATION_COMMAND) { + const { name } = data; + + if (name === 'verify') { + const userId = req.body.member?.user.id || req.body.user.id; + const verifyUrl = `${process.env.BASE_URL}/verify?userId=${userId}`; + + return res.send({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "Click below to verify your NFT ownership!", + components: [{ + type: 1, + components: [{ + type: 2, + style: 5, + label: "Verify NFT", + url: verifyUrl + }] + }], + flags: 64 + }, + }); + } + } +}); + +app.post('/api/verify-role', async (req, res) => { + const { userId } = req.body; + console.log({ userId, guildId: process.env.GUILD_ID, NFT_OWNER_ROLE_ID: process.env.NFT_OWNER_ROLE_ID, discordToken: process.env.DISCORD_TOKEN }) + try { + // Assign NFT Owner role + const response = await fetch( + `https://discord.com/api/v10/guilds/${process.env.GUILD_ID}/members/${userId}/roles/${process.env.NFT_OWNER_ROLE_ID}`, + { + method: 'PUT', + headers: { + 'Authorization': `Bot ${process.env.DISCORD_TOKEN}`, + 'Content-Type': 'application/json', + }, + } + ); + + + if (!response.ok) { + throw new Error('Failed to assign role'); + } + + res.json({ success: true }); + } catch (error) { + console.error('Error:', error); + res.status(500).json({ error: 'Failed to verify' }); + } +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log('Server is running on port', PORT); +}); \ No newline at end of file diff --git a/umbra-helix/discord-server/utils.js b/umbra-helix/discord-server/utils.js new file mode 100644 index 000000000..5111bd0a6 --- /dev/null +++ b/umbra-helix/discord-server/utils.js @@ -0,0 +1,47 @@ +import 'dotenv/config'; + +export async function DiscordRequest(endpoint, options) { + // append endpoint to root API URL + const url = 'https://discord.com/api/v10/' + endpoint; + // Stringify payloads + if (options.body) options.body = JSON.stringify(options.body); + // Use fetch to make requests + const res = await fetch(url, { + headers: { + Authorization: `Bot ${process.env.DISCORD_TOKEN}`, + 'Content-Type': 'application/json; charset=UTF-8', + 'User-Agent': 'DiscordBot (https://github.com/discord/discord-example-app, 1.0.0)', + }, + ...options + }); + // throw API errors + if (!res.ok) { + const data = await res.json(); + console.log(res.status); + throw new Error(JSON.stringify(data)); + } + // return original response + return res; +} + +export async function InstallGlobalCommands(appId, commands) { + // API endpoint to overwrite global commands + const endpoint = `applications/${appId}/commands`; + + try { + // This is calling the bulk overwrite endpoint: https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands + await DiscordRequest(endpoint, { method: 'PUT', body: commands }); + } catch (err) { + console.error(err); + } +} + +// Simple method that returns a random emoji from list +export function getRandomEmoji() { + const emojiList = ['😭', 'πŸ˜„', '😌', 'πŸ€“', '😎', '😀', 'πŸ€–', 'πŸ˜Άβ€πŸŒ«οΈ', '🌏', 'πŸ“Έ', 'πŸ’Ώ', 'πŸ‘‹', '🌊', '✨']; + return emojiList[Math.floor(Math.random() * emojiList.length)]; +} + +export function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/umbra-helix/docs/roadmap.md b/umbra-helix/docs/roadmap.md new file mode 100644 index 000000000..e69de29bb diff --git a/umbra-helix/index.html b/umbra-helix/index.html new file mode 100644 index 000000000..5045a6eda --- /dev/null +++ b/umbra-helix/index.html @@ -0,0 +1,15 @@ + + + + + + + Umbra Helix + + + +
+ + + + \ No newline at end of file diff --git a/umbra-helix/package.json b/umbra-helix/package.json new file mode 100644 index 000000000..2c175b01b --- /dev/null +++ b/umbra-helix/package.json @@ -0,0 +1,66 @@ +{ + "name": "umbra-helix-frontend", + "author": { + "name": "Satyam Bansal", + "email": "satyamsgsits1994@gmail.com" + }, + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "clean": "rm -rf node_modules/.vite dist", + "postinstall": "patch-package", + "server": "nodemon -r dotenv/config discord-server/server.js", + "discord:register": "node discord-server/commands.js" + }, + "devDependencies": { + "@types/node": "^22.3.0", + "@types/react": "^18.3.4", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "daisyui": "^4.12.10", + "dotenv": "^16.0.3", + "dotenv-cli": "^7.4.2", + "nodemon": "^3.0.0", + "patch-package": "^8.0.0", + "postcss": "^8.4.41", + "postinstall-postinstall": "^2.1.0", + "react-error-boundary": "4.0.13", + "tailwindcss": "^3.4.10", + "tailwindcss-animate": "1.0.7", + "typescript": "^5.5.3", + "vite": "^5.4.0" + }, + "dependencies": { + "@aztec/accounts": "0.60.0", + "@aztec/aztec.js": "0.60.0", + "@aztec/circuits.js": "0.60.0", + "@aztec/foundation": "0.60.0", + "@aztec/kv-store": "^0.52.0", + "@aztec/noir-contracts.js": "0.60.0", + "@headlessui/react": "2.1.2", + "@vitejs/plugin-basic-ssl": "^1.1.0", + "buffer": "^6.0.3", + "chalk": "^5.3.0", + "cors": "^2.8.5", + "discord-interactions": "^4.0.0", + "express": "^4.18.2", + "express-session": "^1.18.1", + "jotai": "^2.9.3", + "lucide-react": "^0.439.0", + "next-themes": "0.3.0", + "node-stdlib-browser": "^1.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "react-router-dom": "6.25.1", + "react-use": "^17.5.1", + "sonner": "1.5.0", + "vite-plugin-node-polyfills": "^0.22.0", + "vite-plugin-resolve": "^2.5.2" + } +} diff --git a/umbra-helix/patches/@aztec+circuits.js+0.48.0.patch b/umbra-helix/patches/@aztec+circuits.js+0.48.0.patch new file mode 100644 index 000000000..f1c79ee24 --- /dev/null +++ b/umbra-helix/patches/@aztec+circuits.js+0.48.0.patch @@ -0,0 +1,21 @@ +diff --git a/node_modules/@aztec/circuits.js/dest/scripts/constants.in.js b/node_modules/@aztec/circuits.js/dest/scripts/constants.in.js +index 9d33833..e7e2a80 100644 +--- a/node_modules/@aztec/circuits.js/dest/scripts/constants.in.js ++++ b/node_modules/@aztec/circuits.js/dest/scripts/constants.in.js +@@ -1,4 +1,4 @@ +-import * as fs from 'fs'; ++// import * as fs from 'fs'; + import { dirname, join } from 'path'; + import { fileURLToPath } from 'url'; + const NOIR_CONSTANTS_FILE = '../../../../noir-projects/noir-protocol-circuits/crates/types/src/constants.nr'; +diff --git a/node_modules/@aztec/circuits.js/dest/structs/client_ivc_proof.js b/node_modules/@aztec/circuits.js/dest/structs/client_ivc_proof.js +index 813f31d..542e895 100644 +--- a/node_modules/@aztec/circuits.js/dest/structs/client_ivc_proof.js ++++ b/node_modules/@aztec/circuits.js/dest/structs/client_ivc_proof.js +@@ -1,5 +1,5 @@ + import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; +-import * as fs from 'fs/promises'; ++// import * as fs from 'fs/promises'; + import path from 'path'; + /** + * TODO(https://github.com/AztecProtocol/aztec-packages/issues/7370) refactory this to diff --git a/umbra-helix/postcss.config.js b/umbra-helix/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/umbra-helix/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/umbra-helix/src/App.tsx b/umbra-helix/src/App.tsx new file mode 100644 index 000000000..4b0a5edfa --- /dev/null +++ b/umbra-helix/src/App.tsx @@ -0,0 +1,51 @@ +import React, { useEffect, useState } from "react"; +import { useSetAtom } from "jotai"; +import { pxeAtom } from "./atoms.js"; +import { createPXEClient, PXE, waitForPXE } from "@aztec/aztec.js"; +import { RPC_URL } from "./constants.js"; +import { Toaster } from "react-hot-toast"; +import { ThemeProvider } from "next-themes"; +import { Router } from "./router/router.js"; + +function App() { + const setPXEClient = useSetAtom(pxeAtom); + const [pxeLocal, setPXELocal] = useState() + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + useEffect(() => { + setErrorMessage(""); + setIsLoading(true); + const pxeClient = createPXEClient(RPC_URL); + waitForPXE(pxeClient) + .then((_) => { + setPXEClient(pxeClient) + setPXELocal(pxeClient) + }) + .catch((error) => { + setErrorMessage(error.toString()); + }) + .finally(() => { + setIsLoading(false); + }); + }, []); + + return ( + //
+ //

Aztec Starter

+ // {isLoading &&

Loading ....

} + // {errorMessage &&

{errorMessage}

} + + // {!isLoading && !errorMessage && } + // + //
+ <> + + {errorMessage &&

{errorMessage}

} + +
+ + + ); +} + +export default App; diff --git a/umbra-helix/src/artifacts/NFT.ts b/umbra-helix/src/artifacts/NFT.ts new file mode 100644 index 000000000..01bb6b71b --- /dev/null +++ b/umbra-helix/src/artifacts/NFT.ts @@ -0,0 +1,301 @@ + +/* Autogenerated file, do not edit! */ + +/* eslint-disable */ +import { + type AbiType, + AztecAddress, + type AztecAddressLike, + CompleteAddress, + Contract, + type ContractArtifact, + ContractBase, + ContractFunctionInteraction, + type ContractInstanceWithAddress, + type ContractMethod, + type ContractStorageLayout, + type ContractNotes, + decodeFromAbi, + DeployMethod, + EthAddress, + type EthAddressLike, + EventSelector, + type FieldLike, + Fr, + type FunctionSelectorLike, + L1EventPayload, + loadContractArtifact, + type NoirCompiledContract, + NoteSelector, + Point, + type PublicKey, + PublicKeys, + type UnencryptedL2Log, + type Wallet, + type WrappedFieldLike, +} from '@aztec/aztec.js'; +import NFTContractArtifactJson from '../../contracts/nft_contracts/target/nft_contract-NFT.json' assert { type: 'json' }; +export const NFTContractArtifact = loadContractArtifact(NFTContractArtifactJson as NoirCompiledContract); + + + export type NFTTransfer = { + from: AztecAddressLike +to: AztecAddressLike +token_id: FieldLike + } + + +/** + * Type-safe interface for contract NFT; + */ +export class NFTContract extends ContractBase { + + private constructor( + instance: ContractInstanceWithAddress, + wallet: Wallet, + ) { + super(instance, NFTContractArtifact, wallet); + } + + + + /** + * Creates a contract instance. + * @param address - The deployed contract's address. + * @param wallet - The wallet to use when interacting with the contract. + * @returns A promise that resolves to a new Contract instance. + */ + public static async at( + address: AztecAddress, + wallet: Wallet, + ) { + return Contract.at(address, NFTContract.artifact, wallet) as Promise; + } + + + /** + * Creates a tx to deploy a new instance of this contract. + */ + public static deploy(wallet: Wallet, admin: AztecAddressLike, name: string, symbol: string) { + return new DeployMethod(PublicKeys.default(), wallet, NFTContractArtifact, NFTContract.at, Array.from(arguments).slice(1)); + } + + /** + * Creates a tx to deploy a new instance of this contract using the specified public keys hash to derive the address. + */ + public static deployWithPublicKeys(publicKeys: PublicKeys, wallet: Wallet, admin: AztecAddressLike, name: string, symbol: string) { + return new DeployMethod(publicKeys, wallet, NFTContractArtifact, NFTContract.at, Array.from(arguments).slice(2)); + } + + /** + * Creates a tx to deploy a new instance of this contract using the specified constructor method. + */ + public static deployWithOpts( + opts: { publicKeys?: PublicKeys; method?: M; wallet: Wallet }, + ...args: Parameters + ) { + return new DeployMethod( + opts.publicKeys ?? PublicKeys.default(), + opts.wallet, + NFTContractArtifact, + NFTContract.at, + Array.from(arguments).slice(1), + opts.method ?? 'constructor', + ); + } + + + + /** + * Returns this contract's artifact. + */ + public static get artifact(): ContractArtifact { + return NFTContractArtifact; + } + + + public static get storage(): ContractStorageLayout<'symbol' | 'name' | 'admin' | 'minters' | 'private_nfts' | 'nft_exists' | 'public_owners'> { + return { + symbol: { + slot: new Fr(1n), + }, +name: { + slot: new Fr(2n), + }, +admin: { + slot: new Fr(3n), + }, +minters: { + slot: new Fr(4n), + }, +private_nfts: { + slot: new Fr(5n), + }, +nft_exists: { + slot: new Fr(6n), + }, +public_owners: { + slot: new Fr(7n), + } + } as ContractStorageLayout<'symbol' | 'name' | 'admin' | 'minters' | 'private_nfts' | 'nft_exists' | 'public_owners'>; + } + + + public static get notes(): ContractNotes<'NFTNote' | 'ValueNote'> { + return { + NFTNote: { + id: new NoteSelector(3595710486), + }, +ValueNote: { + id: new NoteSelector(1038582377), + } + } as ContractNotes<'NFTNote' | 'ValueNote'>; + } + + + /** Type-safe wrappers for the public methods exposed by the contract. */ + public declare methods: { + + /** cancel_authwit(inner_hash: field) */ + cancel_authwit: ((inner_hash: FieldLike) => ContractFunctionInteraction) & Pick; + + /** compute_note_hash_and_optionally_a_nullifier(contract_address: struct, nonce: field, storage_slot: field, note_type_id: field, compute_nullifier: boolean, serialized_note: array) */ + compute_note_hash_and_optionally_a_nullifier: ((contract_address: AztecAddressLike, nonce: FieldLike, storage_slot: FieldLike, note_type_id: FieldLike, compute_nullifier: boolean, serialized_note: FieldLike[]) => ContractFunctionInteraction) & Pick; + + /** constructor(admin: struct, name: string, symbol: string) */ + constructor: ((admin: AztecAddressLike, name: string, symbol: string) => ContractFunctionInteraction) & Pick; + + /** finalize_transfer_to_private(token_id: field, hiding_point_slot: field) */ + finalize_transfer_to_private: ((token_id: FieldLike, hiding_point_slot: FieldLike) => ContractFunctionInteraction) & Pick; + + /** get_admin() */ + get_admin: (() => ContractFunctionInteraction) & Pick; + + /** get_private_nfts(owner: struct, page_index: integer) */ + get_private_nfts: ((owner: AztecAddressLike, page_index: (bigint | number)) => ContractFunctionInteraction) & Pick; + + /** is_minter(minter: struct) */ + is_minter: ((minter: AztecAddressLike) => ContractFunctionInteraction) & Pick; + + /** mint(to: struct, token_id: field) */ + mint: ((to: AztecAddressLike, token_id: FieldLike) => ContractFunctionInteraction) & Pick; + + /** owner_of(token_id: field) */ + owner_of: ((token_id: FieldLike) => ContractFunctionInteraction) & Pick; + + /** prepare_transfer_to_private(to: struct) */ + prepare_transfer_to_private: ((to: AztecAddressLike) => ContractFunctionInteraction) & Pick; + + /** private_get_name() */ + private_get_name: (() => ContractFunctionInteraction) & Pick; + + /** private_get_symbol() */ + private_get_symbol: (() => ContractFunctionInteraction) & Pick; + + /** public_dispatch(selector: field) */ + public_dispatch: ((selector: FieldLike) => ContractFunctionInteraction) & Pick; + + /** public_get_name() */ + public_get_name: (() => ContractFunctionInteraction) & Pick; + + /** public_get_symbol() */ + public_get_symbol: (() => ContractFunctionInteraction) & Pick; + + /** set_admin(new_admin: struct) */ + set_admin: ((new_admin: AztecAddressLike) => ContractFunctionInteraction) & Pick; + + /** set_minter(minter: struct, approve: boolean) */ + set_minter: ((minter: AztecAddressLike, approve: boolean) => ContractFunctionInteraction) & Pick; + + /** transfer_in_private(from: struct, to: struct, token_id: field, nonce: field) */ + transfer_in_private: ((from: AztecAddressLike, to: AztecAddressLike, token_id: FieldLike, nonce: FieldLike) => ContractFunctionInteraction) & Pick; + + /** transfer_in_public(from: struct, to: struct, token_id: field, nonce: field) */ + transfer_in_public: ((from: AztecAddressLike, to: AztecAddressLike, token_id: FieldLike, nonce: FieldLike) => ContractFunctionInteraction) & Pick; + + /** transfer_to_private(to: struct, token_id: field) */ + transfer_to_private: ((to: AztecAddressLike, token_id: FieldLike) => ContractFunctionInteraction) & Pick; + + /** transfer_to_public(from: struct, to: struct, token_id: field, nonce: field) */ + transfer_to_public: ((from: AztecAddressLike, to: AztecAddressLike, token_id: FieldLike, nonce: FieldLike) => ContractFunctionInteraction) & Pick; + }; + + + // Partial application is chosen is to avoid the duplication of so much codegen. + private static decodeEvent( + eventSelector: EventSelector, + eventType: AbiType, + ): (payload: L1EventPayload | UnencryptedL2Log | undefined) => T | undefined { + return (payload: L1EventPayload | UnencryptedL2Log | undefined): T | undefined => { + if (payload === undefined) { + return undefined; + } + + if (payload instanceof L1EventPayload) { + if (!eventSelector.equals(payload.eventTypeId)) { + return undefined; + } + return decodeFromAbi([eventType], payload.event.items) as T; + } else { + let items = []; + for (let i = 0; i < payload.data.length; i += 32) { + items.push(new Fr(payload.data.subarray(i, i + 32))); + } + + return decodeFromAbi([eventType], items) as T; + } + }; + } + + public static get events(): { NFTTransfer: {decode: (payload: L1EventPayload | UnencryptedL2Log | undefined) => NFTTransfer | undefined, eventSelector: EventSelector, fieldNames: string[] } } { + return { + NFTTransfer: { + decode: this.decodeEvent(EventSelector.fromSignature('NFTTransfer((Field),(Field),Field)'), { + "kind": "struct", + "path": "NFT::NFTTransfer", + "fields": [ + { + "name": "from", + "type": { + "kind": "struct", + "path": "authwit::aztec::protocol_types::address::aztec_address::AztecAddress", + "fields": [ + { + "name": "inner", + "type": { + "kind": "field" + } + } + ] + } + }, + { + "name": "to", + "type": { + "kind": "struct", + "path": "authwit::aztec::protocol_types::address::aztec_address::AztecAddress", + "fields": [ + { + "name": "inner", + "type": { + "kind": "field" + } + } + ] + } + }, + { + "name": "token_id", + "type": { + "kind": "field" + } + } + ] +}), + eventSelector: EventSelector.fromSignature('NFTTransfer((Field),(Field),Field)'), + fieldNames: ["from","to","token_id"], + } + }; + } + +} diff --git a/umbra-helix/src/assets/HankenGrotesk.ttf b/umbra-helix/src/assets/HankenGrotesk.ttf new file mode 100644 index 000000000..0b92c2236 Binary files /dev/null and b/umbra-helix/src/assets/HankenGrotesk.ttf differ diff --git a/umbra-helix/src/assets/app.css b/umbra-helix/src/assets/app.css new file mode 100644 index 000000000..cb1533b9f --- /dev/null +++ b/umbra-helix/src/assets/app.css @@ -0,0 +1,5 @@ +@import url('reset.css'); + +body { + font-size: 16px; +} diff --git a/umbra-helix/src/assets/fonts.css b/umbra-helix/src/assets/fonts.css new file mode 100644 index 000000000..77fa29b2e --- /dev/null +++ b/umbra-helix/src/assets/fonts.css @@ -0,0 +1,7 @@ +@font-face { + font-family: 'Hanken Grotesk Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(/HankenGrotesk.ttf) format('ttf'); +} diff --git a/umbra-helix/src/assets/reset.css b/umbra-helix/src/assets/reset.css new file mode 100644 index 000000000..18ab9341a --- /dev/null +++ b/umbra-helix/src/assets/reset.css @@ -0,0 +1,74 @@ +/* Box sizing rules */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Remove default margin */ +body, +h1, +h2, +h3, +h4, +p, +figure, +blockquote, +dl, +dd { + margin: 0; +} + +/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ +ul[role='list'], +ol[role='list'] { + list-style: none; +} + +/* Set core root defaults */ +html:focus-within { + scroll-behavior: smooth; +} + +/* Set core body defaults */ +body { + min-height: 100vh; + text-rendering: optimizeSpeed; + line-height: 1.5; +} + +/* A elements that don't have a class get default styles */ +a:not([class]) { + text-decoration-skip-ink: auto; +} + +/* Make images easier to work with */ +img, +picture { + max-width: 100%; + display: block; +} + +/* Inherit fonts for inputs and buttons */ +input, +button, +textarea, +select { + font: inherit; +} + +/* Remove all animations, transitions and smooth scroll for people that prefer not to see them */ +@media (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/umbra-helix/src/atoms.tsx b/umbra-helix/src/atoms.tsx new file mode 100644 index 000000000..b81369aaf --- /dev/null +++ b/umbra-helix/src/atoms.tsx @@ -0,0 +1,26 @@ +import { atom } from 'jotai'; +import { PXE, AccountWalletWithSecretKey } from '@aztec/aztec.js'; +import { type TokenContract, PayTransactionFull } from './types.js' +import { NFTContract } from './artifacts/NFT.js'; + + + + + +// Existing PXE atom +export const pxeAtom = atom(null); +export const walletsAtom = atom([]); + +// Current wallet atom +export const currentWalletAtom = atom(null); +export const currentTokenContractAtom = atom(null); +export const tokenContractsAtom = atom([]); +export const publicBalanceAtom = atom(0n); +export const privateBalanceAtom = atom(0n); + +export const payTransactionsAtom = atom([]); + +export const isPrivateAtom = atom(false); +export const rpcUrlAtom = atom(''); +export const remountKeyAtom = atom(0); +export const nftContractAtom = atom(null) \ No newline at end of file diff --git a/umbra-helix/src/common/assets/logo.svg b/umbra-helix/src/common/assets/logo.svg new file mode 100644 index 000000000..5206dd4e2 --- /dev/null +++ b/umbra-helix/src/common/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/umbra-helix/src/common/lib/string.ts b/umbra-helix/src/common/lib/string.ts new file mode 100644 index 000000000..551441cd4 --- /dev/null +++ b/umbra-helix/src/common/lib/string.ts @@ -0,0 +1,22 @@ +interface TruncateProps { + value: string; + firstCharCount?: number; + endCharCount?: number; + dotCount?: number; +} + +export const truncateString = ({ + value, + firstCharCount = value.length, + endCharCount = 0, + dotCount = 3, +}: TruncateProps) => { + const shouldTruncate = value.length > firstCharCount + endCharCount; + if (!shouldTruncate) return value; + + const firstPortion = value.slice(0, firstCharCount); + const endPortion = value.slice(-endCharCount); + const dots = ".".repeat(dotCount); + + return `${firstPortion}${dots}${endPortion}`; +}; diff --git a/umbra-helix/src/components/AdminPanel.tsx b/umbra-helix/src/components/AdminPanel.tsx new file mode 100644 index 000000000..450f5c05d --- /dev/null +++ b/umbra-helix/src/components/AdminPanel.tsx @@ -0,0 +1,450 @@ +import React, { useState } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { currentWalletAtom, nftContractAtom, walletsAtom } from "../atoms.js"; +import { useAccount } from "../hooks/useAccounts.js"; +import { Spinner } from "./Spinnner.js"; +import { + AztecAddress, + Fr, + PXE, + readFieldCompressedString +} from "@aztec/aztec.js"; +import chalk from "chalk"; +import { toast } from "react-hot-toast"; +import { useSearchParams } from "react-router-dom"; +import { useLoadAccountFromStorage } from "../hooks/useLoadAccountsFromStorage.js"; + +export const AdminPanel = ({ pxe }: { pxe: PXE }) => { + const pxeClient = pxe; + const [isInProgressObj, setIsInProgressObj] = useState<{ + [key: string]: boolean; + }>({}); + const [nftContract, setNFTContract] = useAtom(nftContractAtom); + const [NFTMintAddress, setNFTMintAddress] = useState(""); + const [currentWallet] = useAtom(currentWalletAtom) + + const { deployNFTContract } = useAccount(pxeClient) + const [tokenId, setTokenId] = useState(0) + + const [searchParams] = useSearchParams(); + const userId = searchParams.get("userId"); + const [formData, setFormData] = useState({ + name: '', + symbol: '' + }); + useLoadAccountFromStorage(pxe) + + + const handleDeployNFTContract = async () => { + if (!currentWallet) { + console.error("Current Wallet not found!"); + return; + } + setIsInProgressObj({ ...isInProgressObj, nftContract: true }); + console.log("Deploying token"); + const nftContract = await deployNFTContract( + currentWallet, + formData.name, + formData.symbol + ); + setNFTContract(nftContract); + + setIsInProgressObj({ ...isInProgressObj, nftContract: false }); + }; + + const handleMintNFT = async () => { + if (!nftContract || !currentWallet) { + console.error("no contract or addrees"); + return; + } + try { + setIsInProgressObj({ ...isInProgressObj, isMintingNFT: true }); + const tx = await nftContract.methods + .mint(AztecAddress.fromString(NFTMintAddress), tokenId) + .send(); + + console.log(`Sent nft mint transaction ${await tx.getTxHash()}`); + console.log(chalk.blackBright("Awaiting transaction to be mined")); + const receipt = await tx.wait(); + console.log( + chalk.green( + `Transaction has been mined on block ${chalk.bold(receipt.blockNumber)}` + ) + ); + toast.success(`NFT Minted successfully`) + } catch (error: any) { + toast.error(`NFT Mint error`, error.toString()) + } finally { + setIsInProgressObj({ ...isInProgressObj, isMintingNFT: false }); + } + }; + + const handlePublicTransferNFT = async () => { + if (!nftContract || !currentWallet) { + console.error("no contract or addrees"); + return; + } + setIsInProgressObj({ + ...isInProgressObj, + isPublicTransferNFTInProgress: true, + }); + const tx = await nftContract.methods + .transfer_in_public( + currentWallet.getAddress(), + AztecAddress.fromString(NFTMintAddress), + tokenId, + 0 + ) + .send(); + + console.log(`Sent public nft transfer transaction ${await tx.getTxHash()}`); + console.log(chalk.blackBright("Awaiting transaction to be mined")); + const receipt = await tx.wait(); + console.log( + chalk.green( + `Transaction has been mined on block ${chalk.bold(receipt.blockNumber)}` + ) + ); + setIsInProgressObj({ + ...isInProgressObj, + isPublicTransferNFTInProgress: false, + }); + }; + + const handleFetchNFTOwner = async () => { + if (!nftContract) { + console.error("no contract or addrees"); + return; + } + + try { + setIsInProgressObj({ ...isInProgressObj, isFetchingNFTOwner: true }); + const owner = await nftContract.methods.owner_of(tokenId).simulate(); + toast.success(`Owner of token Id ${tokenId}: ${owner}`); + } catch (error: any) { + toast.error(error.toString()); + } finally { + setIsInProgressObj({ ...isInProgressObj, isFetchingNFTOwner: false }); + } + }; + + const handlePreaparePrivateTransferNFT = async () => { + if (!nftContract || !currentWallet) { + console.error("no contract or addrees"); + return; + } + + try { + setIsInProgressObj({ + ...isInProgressObj, + isPreparePrivateTransferNFTInProgress: true, + }); + const slotRandomness = Fr.random(); + // const tx = await nftContract.methods.prepare_transfer_to_private(currentWallet.getAddress(), AztecAddress.fromString(NFTMintAddress), slotRandomness).send() + const tx = await nftContract.methods + .transfer_to_private(AztecAddress.fromString(NFTMintAddress), tokenId) + .send(); + console.log(`Private transfer transaction ${await tx.getTxHash()}`); + console.log(chalk.blackBright("Awaiting transaction to be mined")); + const receipt = await tx.wait(); + console.log( + chalk.green( + `Transaction has been mined on block ${chalk.bold( + receipt.blockNumber + )}` + ) + ); + toast.success("Private Transfer done"); + } catch (error: any) { + toast.error(error.toString()); + } finally { + setIsInProgressObj({ + ...isInProgressObj, + isPreparePrivateTransferNFTInProgress: false, + }); + } + }; + + const handleFetchPrivateNFTTokenIds = async () => { + let nfts: [number[], boolean] = [[], false]; + if (!nftContract || !currentWallet) { + console.error("no contract or addrees"); + return nfts; + } + + try { + setIsInProgressObj({ + ...isInProgressObj, + isFetchPrivateNFTTokenIds: true, + }); + nfts = await nftContract.methods + .get_private_nfts(currentWallet.getAddress(), 0) + .simulate(); + toast.success(`Private NFTS: ${nfts}`); + } catch (error: any) { + toast.error(error.toString()); + } finally { + setIsInProgressObj({ + ...isInProgressObj, + isFetchPrivateNFTTokenIds: false, + }); + return nfts; + } + }; + + const handleVerifyWallet = async () => { + //!! I commented this line because after fixing route issue /verify route was giving error of /verify route not found + // const pathname = window.location.pathname; + // const pathParts = pathname.split("/").filter((path) => path !== ""); + // if (pathParts.length !== 2 || pathParts[0] !== "verify") { + // return toast.error(`Invalid path. path should follow /verify/`); + // } + let privateNFTResponse = await handleFetchPrivateNFTTokenIds(); + console.log("PRIVATEN NFT RESPONSE", privateNFTResponse); + const [tokenIds = [], isMoreNfts] = privateNFTResponse; + // console.log("Private NFTS", privateNFTs) + + const nonZeroTokenIds = tokenIds.filter( + (nftTokenId: number) => nftTokenId !== 0 + ); + if (nonZeroTokenIds.length === 0) { + return toast.error(`Current wallet is not an NFT holder`); + } + + toast.success( + "You are an NFT holder \n So you assigned a role of the NFT owner in our discord server" + ); + + try { + console.log("verifying role"); + //TODO : HERE ARE THE MAIN CODE TO VERIFY ROLE + const response = await fetch( + //!! This code is printing like http://localhost:5173/undefined/api/verify-role i think the reason is it's not in the API folder + `${process.env.REACT_APP_API_URL}/api/verify-role`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ userId }), + } + ); + + const data = await response.json(); + + if (data.success) { + setTimeout(() => { + window.close(); + }, 3000); + + console.log("verified role"); + } else { + throw new Error(data.error || "Verification failed"); + } + } catch (err) { + console.log("ERROR in verify", err); + // setError(err.message || "Failed to verify. Please try again."); + } finally { + // setVerifying(false); + } + }; + + return ( +
+

ADMIN PANEL

+
+
+
+ + + +
+ + {/** Mint NFT Flow Starts*/} +
+
+

Mint NFT

+ + + +
+
+ {/** Mint NFT Flow Ends*/} + + {/** Public Transfer NFT Flow Starts*/} +
+
+

Public Transfer NFT

+ + + +
+
+ {/** Public Transfer NFT Flow Ends*/} + + {/** Fetch NFT Owner Flow Starts*/} +
+
+

Check NFT Owner

+ + + + +
+
+ {/** Fetch NFT Owner Flow Ends*/} + + {/** Private Transfer NFT Flow Starts*/} +
+
+

Public to Private NFT Transfer

+ + + + +
+
+ {/** Private Transfer NFT Flow Ends*/} + + +
+
+ {currentWallet && ( +

+ Current Wallet Address: + + {currentWallet.getAddress().toString()} + +

+ )} + {nftContract && ( +

+ Deployed NFT Contract Address: + + {nftContract.address.toString()} + +

+ )} +
+
+
+ ); +}; diff --git a/umbra-helix/src/components/AppLayout.tsx b/umbra-helix/src/components/AppLayout.tsx new file mode 100644 index 000000000..7a11d0a68 --- /dev/null +++ b/umbra-helix/src/components/AppLayout.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Spinner } from './Spinnner.js'; +import Header from './Header.js'; +import { PXE } from '@aztec/aztec.js'; + +const AppLayout = ({ pxe, children, isLoading, errorMessage }: { + pxe?: PXE, + children: React.ReactNode, + isLoading: boolean, + errorMessage: string +}) => { + + if (isLoading) { + return ( +
+
+ +

Initializing PXE...

+
+
+ ); + } + if (errorMessage) { + return ( +
+
+

Initialization Error

+

{errorMessage}

+ +
+
+ ); + } + + return ( +
+
+
+ {children} +
+
+ ); +}; + +export default AppLayout; \ No newline at end of file diff --git a/umbra-helix/src/components/Header.tsx b/umbra-helix/src/components/Header.tsx new file mode 100644 index 000000000..0fd83f5d3 --- /dev/null +++ b/umbra-helix/src/components/Header.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { currentWalletAtom, walletsAtom } from '../atoms.js'; +import { useAccount } from '../hooks/useAccounts.js'; +import { Spinner } from './Spinnner.js'; +import { Link, useLocation } from 'react-router-dom'; +import { PXE } from '@aztec/aztec.js'; +import { useLoadAccountFromStorage } from '../hooks/useLoadAccountsFromStorage.js'; + +const Header = ({ pxe }: { pxe: PXE }) => { + const [currentWallet, setCurrentWallet] = useAtom(currentWalletAtom); + const wallets = useAtomValue(walletsAtom); + const { createAccount, isCreating } = useAccount(pxe); + const [isWalletDropdownOpen, setIsWalletDropdownOpen] = useState(false); + const location = useLocation(); + + const shortenAddress = (address: string) => { + const str = address.toString(); + return `${str.slice(0, 6)}...${str.slice(-4)}`; + }; + + useLoadAccountFromStorage(pxe); + + return ( +
+
+
+ + + {/* Wallet Management */} +
+
+ + + {/* Wallet Dropdown */} + {isWalletDropdownOpen && ( +
+
+ {wallets.map((wallet, index) => ( + + ))} + + {/* Create New Wallet Button */} + +
+
+ )} +
+
+
+
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/umbra-helix/src/components/Spinnner.tsx b/umbra-helix/src/components/Spinnner.tsx new file mode 100644 index 000000000..98b350231 --- /dev/null +++ b/umbra-helix/src/components/Spinnner.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export const Spinner = () => { + return ( +
+
+
+ ); +}; diff --git a/umbra-helix/src/components/VerifyWallet.tsx b/umbra-helix/src/components/VerifyWallet.tsx new file mode 100644 index 000000000..93adb9347 --- /dev/null +++ b/umbra-helix/src/components/VerifyWallet.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from "react"; +import { useAtom } from "jotai"; +import { currentWalletAtom, nftContractAtom } from "../atoms.js"; +import { Spinner } from "./Spinnner.js"; +import { useSearchParams } from "react-router-dom"; +import { toast } from "react-hot-toast"; +import { PXE, readFieldCompressedString } from "@aztec/aztec.js"; + +export const VerifyWallet = ({ pxe }: { pxe: PXE }) => { + const [currentWallet] = useAtom(currentWalletAtom); + const [nftContract] = useAtom(nftContractAtom); + const [isVerifying, setIsVerifying] = useState(false); + const [isVerified, setIsVerified] = useState(false); + const [searchParams] = useSearchParams(); + const userId = searchParams.get("userId"); + const [isFetching, setIsFetching] = useState(false) + const [nftName, setNFTName] = useState('') + const [nftSymbol, setNFTSymbol] = useState('') + + + useEffect(() => { + if (nftContract) { + setIsFetching(true) + Promise.all([nftContract.methods.public_get_name?.().simulate(), nftContract.methods.public_get_symbol?.().simulate(),]) + .then(([nftName, nftSymbol]) => { + console.log({ nftName, nftSymbol }); + const nftNameStr = readFieldCompressedString(nftName) + setNFTName(nftNameStr); + setNFTSymbol(readFieldCompressedString(nftSymbol)) + }) + .catch((error: any) => { + console.error("Failed to fetch NFT name and symbol") + toast.error("Failed to fetch NFT name and symbol", error) + }).finally(() => { + setIsFetching(false) + }) + } + }, [nftContract]) + + + const handleVerification = async () => { + if (!currentWallet || !nftContract) { + toast.error("Please connect your wallet using the header menu"); + return; + } + + try { + setIsVerifying(true); + + // Fetch private NFTs + const [tokenIds = [], isMoreNfts] = await nftContract.methods + .get_private_nfts(currentWallet.getAddress(), 0) + .simulate(); + + const nonZeroTokenIds = tokenIds.filter( + (nftTokenId: number) => nftTokenId !== 0 + ); + console.log("Token IDs", tokenIds) + + if (nonZeroTokenIds.length === 0) { + toast.error("You don't own any NFTs from this collection"); + return; + } + + // Call API to verify role + const response = await fetch( + `http://localhost:3000/api/verify-role`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ userId }), + } + ); + + const data = await response.json(); + + if (data.success) { + setIsVerified(true); + toast.success("Verification successful! You can now close this window."); + setTimeout(() => { + window.close(); + }, 5000); + } else { + throw new Error(data.error || "Verification failed"); + } + } catch (error: any) { + toast.error(`Verification failed: ${error.toString()}`); + } finally { + setIsVerifying(false); + } + }; + + if (!userId) { + return ( +
+
+
+ + + +
+

Invalid Verification Request

+

Missing user ID parameter. Please try the verification through Discord.

+
+
+ ); + } + + return ( +
+
+
+ {/* Header Section */} +
+

+ NFT Ownership Verification +

+
+ + {/* Content Section */} +
+ {nftContract && ( +
+
+
+

Collection Name:

+

+ {isFetching ? Loading... : {nftName}} +

+
+
+

Symbol:

+

+ {isFetching ? Loading... : {nftSymbol}} +

+
+
+

Address:

+

+ {nftContract.address.toShortString()} +

+
+
+
+ )} + + {/* Verification Status and Button */} +
+ {isVerified ? ( +
+
+ + + +
+

+ Verification Successful! +

+

+ You can now close this window and return to Discord. + Your NFT OWNER role has been assigned. +

+
+ ) : ( + <> +

+ Click the button below to verify your NFT ownership and receive the NFT OWNER role in Discord. +

+ + + {(!currentWallet || !nftContract) && ( +

+ Please ensure your wallet is connected using the header menu +

+ )} + + )} +
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/umbra-helix/src/components/create-wallet.tsx b/umbra-helix/src/components/create-wallet.tsx new file mode 100644 index 000000000..54eec61ef --- /dev/null +++ b/umbra-helix/src/components/create-wallet.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { MenuBar } from "./menu-bar.js"; +import { ArrowRightIcon } from "lucide-react"; + +export const CreateWallet = () => { + return ( +
+ +
+
+

+ Portfolio value +

+ +
+

+ + Y + 20.78 + +

+

1.23 (23h)

+
+ + +
+
+
+
+
+

Recent

+

Transactions

+
+ + See all + + +
+
+

No transactions yet.

+
+
+
+ ); +}; diff --git a/umbra-helix/src/components/menu-bar.tsx b/umbra-helix/src/components/menu-bar.tsx new file mode 100644 index 000000000..2fabdb143 --- /dev/null +++ b/umbra-helix/src/components/menu-bar.tsx @@ -0,0 +1,187 @@ +// import Logo from "@/common/assets/logo.svg?react"; +// import { truncateString } from "@/common/lib/string"; +import { + ArrowLeftIcon, + ChevronDownIcon, + ChevronUpIcon, + XIcon, +} from "lucide-react"; +import React from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { truncateString } from "../common/lib/string.js"; +import { MenuDrawer } from "./menu-drawer.js"; +// import { MenuDrawer } from "./menu-drawer"; + +type MenuBarBaseProps = { + leftSlot?: React.ReactNode; + children?: React.ReactNode; + rightSlot?: React.ReactNode; +}; + +export const MenuBarBase = ({ + leftSlot, + children, + rightSlot, +}: MenuBarBaseProps) => { + return ( + + ); +}; + +const LogoButton = ({ onClick }: { onClick?: () => void }) => { + return ( + + ); +}; + +type MenuBarProps = { + variant: "dashboard" | "wallet" | "card" | "back" | "back-stop" | "stop"; + onBackClicked?: () => void; + onCloseClicked?: () => void; + onAddressClicked?: () => void; + onNetworkClicked?: () => void; + networkManagement?: boolean; + publicAddress?: string; + currentNetwork?: string; +}; + +export const MenuBar = ({ + variant, + onBackClicked, + onCloseClicked, + onNetworkClicked, + networkManagement = false, + publicAddress, + currentNetwork, +}: MenuBarProps) => { + const navigate = useNavigate(); + const goHome = () => navigate("/dashboard"); + switch (variant) { + case "dashboard": + return ( + } + rightSlot={} + > + {(publicAddress?.length ?? 0) > 0 && ( + + {truncateString({ + value: publicAddress ?? "", + firstCharCount: 5, + endCharCount: 3, + })} + + )} + + ); + case "wallet": + return ( + } + rightSlot={ +
+
+ +

{currentNetwork}

+ {networkManagement ? ( + + ) : ( + + )} + +
+ +
+ } + /> + ); + case "card": + return ( + } + rightSlot={ +
+ +
+ } + /> + ); + case "back": + return ( + + + + } + /> + ); + case "back-stop": + return ( + + + + } + rightSlot={ + + } + /> + ); + case "stop": + return ( + + + + } + /> + ); + } +}; diff --git a/umbra-helix/src/components/menu-drawer.tsx b/umbra-helix/src/components/menu-drawer.tsx new file mode 100644 index 000000000..547ed89c2 --- /dev/null +++ b/umbra-helix/src/components/menu-drawer.tsx @@ -0,0 +1,105 @@ +// import Logo from "@/common/assets/logo.svg?react"; +// import MenuIcon from "@/common/assets/menu.svg?react"; +import { ChevronDownIcon, XIcon } from "lucide-react"; +import React from "react"; +import { Link, useNavigate } from "react-router-dom"; + +export const MenuDrawer = () => { + const navigate = useNavigate(); + return ( +
+ +
+
+ +
+
+
+
+
+ ); +}; diff --git a/umbra-helix/src/components/wizard-layout.tsx b/umbra-helix/src/components/wizard-layout.tsx new file mode 100644 index 000000000..68e078883 --- /dev/null +++ b/umbra-helix/src/components/wizard-layout.tsx @@ -0,0 +1,45 @@ +import clsx from "clsx"; +import { ArrowLeftIcon } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +// import { MenuBar } from "./menu-bar"; +import React from "react"; +import { MenuBar } from "./menu-bar.js"; + +interface WizardLayoutProps { + children: React.ReactNode; + footer?: React.ReactNode; + title?: React.ReactNode; + backButtonPath?: string | number; + headerShown?: boolean; +} + +export const WizardLayout = ({ + children, + footer, + title, + backButtonPath, + headerShown = true, +}: WizardLayoutProps) => { + const navigate = useNavigate(); + return ( +
+ {headerShown && ( + navigate(backButtonPath as never)} + /> + )} + {title && ( +

+ {title} +

+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+ ); +}; diff --git a/umbra-helix/src/constants.tsx b/umbra-helix/src/constants.tsx new file mode 100644 index 000000000..4fe4ec169 --- /dev/null +++ b/umbra-helix/src/constants.tsx @@ -0,0 +1,9 @@ +export const RPC_URL = 'http://localhost:8080' +// export const RPC_URL = 'http://98.70.35.172:8080' +export const CREATE_ACCOUNT_DEFAULT_PARAMS = { + type: 'schnorr', + wait: true, + rpcUrl: RPC_URL, +} +export const ACCOUNTS_STORAGE_KEY = "aztec_accounts_key" +export const NFT_CONTRACT_KEY = "nft_contract_latest" diff --git a/umbra-helix/src/error-renderer/views/error.tsx b/umbra-helix/src/error-renderer/views/error.tsx new file mode 100644 index 000000000..5ff342026 --- /dev/null +++ b/umbra-helix/src/error-renderer/views/error.tsx @@ -0,0 +1,50 @@ +import type { FallbackProps } from "react-error-boundary" + +export const ErrorView = ({ error, resetErrorBoundary }: FallbackProps) => { + const stringifiedError = JSON.stringify( + error, + Object.getOwnPropertyNames(error), + ) + const report = async () => { + resetErrorBoundary() + } + return ( +
+
+
+

Woops

+

An error happened.

+ + Check services status. + +