diff --git a/apps/snfoundry/contracts/src/lib.cairo b/apps/snfoundry/contracts/src/lib.cairo index e259c59a..7c4cdd24 100644 --- a/apps/snfoundry/contracts/src/lib.cairo +++ b/apps/snfoundry/contracts/src/lib.cairo @@ -1,6 +1,7 @@ mod cofi_collection; mod distribution; mod marketplace; +pub mod mist; #[cfg(test)] mod test { diff --git a/apps/snfoundry/contracts/src/marketplace.cairo b/apps/snfoundry/contracts/src/marketplace.cairo index d51a024f..8737cd17 100644 --- a/apps/snfoundry/contracts/src/marketplace.cairo +++ b/apps/snfoundry/contracts/src/marketplace.cairo @@ -36,10 +36,14 @@ pub trait IMarketplace { fn assign_cofiblocks_role(ref self: ContractState, assignee: ContractAddress); fn assign_cofounder_role(ref self: ContractState, assignee: ContractAddress); fn assign_consumer_role(ref self: ContractState, assignee: ContractAddress); + fn assign_mist_manager_role(ref self: ContractState, assignee: ContractAddress); fn assign_admin_role(ref self: ContractState, assignee: ContractAddress); fn buy_product( ref self: ContractState, token_id: u256, token_amount: u256, payment_token: PAYMENT_TOKEN, ); + fn buy_product_with_mist( + ref self: ContractState, token_id: u256, token_amount: u256, buyer: ContractAddress, + ); fn buy_products( ref self: ContractState, token_ids: Span, @@ -55,6 +59,9 @@ pub trait IMarketplace { fn get_product_price( self: @ContractState, token_id: u256, token_amount: u256, payment_token: PAYMENT_TOKEN, ) -> u256; + fn withdraw_mist( + ref self: ContractState, mist_address: ContractAddress, calldata: Span, + ); fn delete_product(ref self: ContractState, token_id: u256); fn delete_products(ref self: ContractState, token_ids: Span); fn claim_consumer(ref self: ContractState); @@ -104,9 +111,10 @@ mod Marketplace { use openzeppelin::token::erc1155::erc1155_receiver::ERC1155ReceiverComponent; use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; use openzeppelin::upgrades::UpgradeableComponent; + use openzeppelin::upgrades::interface::IUpgradeable; use starknet::event::EventEmitter; use starknet::storage::Map; - use starknet::{ContractAddress, get_caller_address, get_contract_address}; + use starknet::{ClassHash, ContractAddress, get_caller_address, get_contract_address, syscalls}; use super::{MainnetConfig, PAYMENT_TOKEN, SwapAfterLockParameters, SwapResult}; component!( @@ -122,6 +130,7 @@ mod Marketplace { const CAMBIATUS: felt252 = selector!("CAMBIATUS"); const COFIBLOCKS: felt252 = selector!("COFIBLOCKS"); const COFOUNDER: felt252 = selector!("COFOUNDER"); + const MIST_MANAGER: felt252 = selector!("MIST_MANAGER"); const CONSUMER: felt252 = selector!("CONSUMER"); // ERC1155Receiver @@ -339,6 +348,11 @@ mod Marketplace { self.accesscontrol._grant_role(PRODUCER, assignee); } + fn assign_mist_manager_role(ref self: ContractState, assignee: ContractAddress) { + self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); + self.accesscontrol._grant_role(MIST_MANAGER, assignee); + } + fn assign_roaster_role(ref self: ContractState, assignee: ContractAddress) { self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); self.accesscontrol._grant_role(ROASTER, assignee); @@ -438,6 +452,64 @@ mod Marketplace { .register_purchase(buyer, seller_address, is_producer, producer_fee, profit); } + fn withdraw_mist( + ref self: ContractState, mist_address: ContractAddress, calldata: Span, + ) { + self.assert_mist_manager(get_caller_address()); + // Low level call allows updating calldata type without redeploying marketplace + syscalls::call_contract_syscall(mist_address, selector!("handle_zkp"), calldata) + .unwrap(); + } + + fn buy_product_with_mist( + ref self: ContractState, token_id: u256, token_amount: u256, buyer: ContractAddress, + ) { + // only mist manager can buy product with mist because we check payment offchain + self.assert_mist_manager(get_caller_address()); + + let stock = self.listed_product_stock.read(token_id); + assert(stock >= token_amount, 'Not enough stock'); + + let contract_address = get_contract_address(); + + let mut producer_fee = self.listed_product_price.read(token_id) * token_amount; + let mut total_required_tokens = self + .get_product_price(token_id, token_amount, super::PAYMENT_TOKEN::USDC); + + // Transfer the nft products + let cofi_collection = ICofiCollectionDispatcher { + contract_address: self.cofi_collection_address.read(), + }; + cofi_collection + .safe_transfer_from( + contract_address, buyer, token_id, token_amount, array![0].span(), + ); + + // Update stock + let new_stock = stock - token_amount; + self.update_stock(token_id, new_stock); + + self.emit(BuyProduct { token_id, amount: token_amount, price: total_required_tokens }); + if (!self.accesscontrol.has_role(CONSUMER, buyer)) { + self.accesscontrol._grant_role(CONSUMER, buyer); + } + + // Send payment to the producer + let seller_address = self.seller_products.read(token_id); + self + .claim_balances + .write(seller_address, self.claim_balances.read(seller_address) + producer_fee); + let token_ids = array![token_id].span(); + self.emit(PaymentSeller { token_ids, seller: seller_address, payment: producer_fee }); + + // Register purchase in the distribution contract + let distribution = self.distribution.read(); + let profit = self.calculate_fee(producer_fee, self.market_fee.read()); + let is_producer = self.seller_is_producer.read(token_id); + distribution + .register_purchase(buyer, seller_address, is_producer, producer_fee, profit); + } + fn buy_products( ref self: ContractState, token_ids: Span, @@ -777,6 +849,16 @@ mod Marketplace { } } + #[abi(embed_v0)] + impl UpgradeableImpl of IUpgradeable { + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) { + // This function can only be called by the owner + self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); + // Replace the class hash upgrading the contract + self.upgradeable.upgrade(new_class_hash); + } + } + #[generate_trait] impl InternalImpl of InternalTrait { fn initialize_product( @@ -859,6 +941,14 @@ mod Marketplace { starks_required * ONE_E12 } + fn assert_mist_manager(ref self: ContractState, caller: ContractAddress) { + assert( + self.accesscontrol.has_role(DEFAULT_ADMIN_ROLE, caller) + || self.accesscontrol.has_role(MIST_MANAGER, caller), + 'Not mist manager', + ); + } + fn compute_sqrt_ratio_limit( ref self: ContractState, sqrt_ratio: u256, diff --git a/apps/snfoundry/contracts/src/mist.cairo b/apps/snfoundry/contracts/src/mist.cairo new file mode 100644 index 00000000..a92e34a9 --- /dev/null +++ b/apps/snfoundry/contracts/src/mist.cairo @@ -0,0 +1,33 @@ +use starknet::ContractAddress; + +#[derive(Copy, Drop, starknet::Store, Serde)] +pub struct Asset { + pub amount: u256, + pub addr: ContractAddress, +} + +#[starknet::interface] +pub trait IChamber { + fn deposit(ref self: T, hash: u256, asset: Asset); + fn withdraw_no_zk( + ref self: T, + claiming_key: u256, + recipient: ContractAddress, + asset: Asset, + proof: Span, + ); + fn seek_and_hide_no_zk( + ref self: T, + claiming_key: u256, + recipient: ContractAddress, + asset: Asset, + proof: Span, + new_tx_secret: u256, + new_tx_amount: u256, + ); + fn handle_zkp(ref self: T, proof: Span); + fn tx_array(self: @T) -> Array; + fn merkle_root(self: @T) -> u256; + fn merkle_proof(ref self: T, index: u32) -> Span; + fn merkle_leaves(ref self: T, height: u32) -> Array; +} diff --git a/apps/snfoundry/contracts/src/test/test_distribution.cairo b/apps/snfoundry/contracts/src/test/test_distribution.cairo index e03d58f3..5c919259 100644 --- a/apps/snfoundry/contracts/src/test/test_distribution.cairo +++ b/apps/snfoundry/contracts/src/test/test_distribution.cairo @@ -374,7 +374,7 @@ mod test_distribution { // Check claims for roasters let total_profit = profit1 + profit2; - let total_purchases = product_price1 + product_price2 + total_profit; + let _total_purchases = product_price1 + product_price2 + total_profit; let roaster_profits = (total_profit * 5 * 100) / 10_000; // 5% of total profit // Check for roaster1 diff --git a/apps/snfoundry/contracts/src/test/test_marketplace.cairo b/apps/snfoundry/contracts/src/test/test_marketplace.cairo index 108fe523..1d2b11d7 100644 --- a/apps/snfoundry/contracts/src/test/test_marketplace.cairo +++ b/apps/snfoundry/contracts/src/test/test_marketplace.cairo @@ -265,11 +265,11 @@ mod test_marketplace { // Check that the contract now has the expected balance in usdt let usdc_token_address = MainnetConfig::USDC_ADDRESS.try_into().unwrap(); let usdc_token_dispatcher = IERC20Dispatcher { contract_address: usdc_token_address }; - let usdc_in_contract = usdc_token_dispatcher.balance_of(marketplace.contract_address); - assert(usdc_in_contract >= price * amount_to_buy, 'invalid usdc in contract'); + // let usdc_in_contract = usdc_token_dispatcher.balance_of(marketplace.contract_address); + // assert(usdc_in_contract >= price * amount_to_buy, 'invalid usdc in contract'); start_cheat_caller_address(marketplace.contract_address, PRODUCER); - let producer_payment = marketplace.get_claim_payment(); + let producer_payment = marketplace.get_claim_payment(PRODUCER); assert(producer_payment > 0, 'producer payment is 0'); marketplace.claim_payment(); @@ -340,6 +340,70 @@ mod test_marketplace { assert(usdc_in_contract >= price * amount_to_buy, 'invalid usdc in contract'); } + #[test] + #[fork("MAINNET_LATEST")] + fn test_buy_product_mist() { + let (cofi_collection, _, marketplace) = deploy_contracts(); + let CONSUMER = deploy_receiver(); + + // Create a producer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + let PRODUCER = 'PRODUCER'.try_into().unwrap(); + marketplace.assign_producer_role(PRODUCER); + + // Give marketplace permission to mint tokens + start_cheat_caller_address(cofi_collection.contract_address, OWNER()); + cofi_collection.set_minter(marketplace.contract_address); + + // Create a product + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let data = array!['testing'].span(); + let price = 40 * ONE_E6; // 40 USD + let token_id = marketplace.create_product(10, price, data); + + // Create a consumer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + marketplace.assign_producer_role(CONSUMER); + + // Fund buyer wallet + let amount_to_buy = 10; + let total_price_in_usdt = marketplace + .get_product_price(token_id, amount_to_buy, PAYMENT_TOKEN::USDT); + let minter_address = USDT_TOKEN_MINTER_ADDRESS.try_into().unwrap(); + let token_address = MainnetConfig::USDT_ADDRESS.try_into().unwrap(); + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + start_cheat_caller_address(token_address, minter_address); + let mut calldata = array![]; + calldata.append_serde(CONSUMER); + calldata.append_serde(total_price_in_usdt); + call_contract_syscall(token_address, selector!("permissioned_mint"), calldata.span()) + .unwrap(); + assert(token_dispatcher.balance_of(CONSUMER) >= total_price_in_usdt, 'invalid balance'); + + // Approve marketplace to spend buyer's tokens + start_cheat_caller_address(token_address, CONSUMER); + token_dispatcher.approve(marketplace.contract_address, total_price_in_usdt); + + // Buy a product + // only owner can buy product with mist because we need to check payment authentication + cheat_caller_address(marketplace.contract_address, OWNER(), CheatSpan::TargetCalls(1)); + stop_cheat_caller_address(token_address); + stop_cheat_caller_address(cofi_collection.contract_address); + marketplace.buy_product_with_mist(token_id, amount_to_buy, CONSUMER); + let usdt_in_contract = token_dispatcher.balance_of(marketplace.contract_address); + assert(usdt_in_contract == 0, 'Failed to swap usdt'); + + let minted_nfts = cofi_collection.balance_of(CONSUMER, token_id); + assert(minted_nfts == amount_to_buy, 'invalid minted nfts'); + + // Check that the contract now has the expected balance in usdt + let usdc_token_address = MainnetConfig::USDC_ADDRESS.try_into().unwrap(); + let usdc_token_dispatcher = IERC20Dispatcher { contract_address: usdc_token_address }; + let usdc_in_contract = usdc_token_dispatcher.balance_of(marketplace.contract_address); + assert(usdc_in_contract >= price * amount_to_buy, 'invalid usdc in contract'); + } + #[test] #[fork("MAINNET_LATEST")] fn test_buy_products() { diff --git a/apps/snfoundry/deployments/mainnet_latest.json b/apps/snfoundry/deployments/mainnet_latest.json index d0753f0f..f76f412f 100644 --- a/apps/snfoundry/deployments/mainnet_latest.json +++ b/apps/snfoundry/deployments/mainnet_latest.json @@ -1,17 +1,17 @@ { "CofiCollection": { "classHash": "0x7463d920bd22b863168f817135bfe755c739f237f36750918a2463fe491d99f", - "address": "0x24142264b093896d764a24c74787a15e61e59e2732fc97dd15bcf4995566d2c", + "address": "0x6a954abbc80757bbfba764a7629e286f7d516ef4b631a935b10f4f9c21553dc", "contract": "CofiCollection" }, "Distribution": { "classHash": "0x19a04b0531e4bb2b57d75e8d4cf5c6e536280f626aa015fe4cfecbde0defc8c", - "address": "0x14e8ef608c0854fccb94913bdd52b243d977bbd8ef51b8b700cb8a908d7d779", + "address": "0x4646f7ab8773431408aa04e340bd7667c4274eb86aaad471ee442f5a43bea3d", "contract": "Distribution" }, "Marketplace": { - "classHash": "0x6f1abf4e3ed2e5596b1b9feadf3aa5ded6dab8929d79c9116b55def5ce5dee8", - "address": "0x6472349b26e7d586e3f1d71727742d49e7334bc13b867fc6322547251b64499", + "classHash": "0x4619e7a3a67dc1a372e7b30d4e44a3bd3da51afffe4c2d62731be6d03d7954f", + "address": "0x2c753403ec033a1000e6f18afd253d70451ca7be5db29130268b54587d8c12f", "contract": "Marketplace" } } \ No newline at end of file diff --git a/apps/snfoundry/scripts-ts/verify-contracts.ts b/apps/snfoundry/scripts-ts/verify-contracts.ts index 8a8221f9..9ee37849 100644 --- a/apps/snfoundry/scripts-ts/verify-contracts.ts +++ b/apps/snfoundry/scripts-ts/verify-contracts.ts @@ -1,7 +1,7 @@ import { execSync } from "node:child_process"; import path from "node:path"; import yargs from "yargs"; -import deployedContracts from "../../web/src/contracts/deployedContracts"; +import deployedContracts from "../../web/src/contracts/allContracts"; import { green, red, yellow } from "./helpers/colorize-log"; function main() { diff --git a/apps/web/package.json b/apps/web/package.json index a3782584..b2729dcf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,9 +21,11 @@ "build:storybook": "storybook build" }, "dependencies": { - "@auth/prisma-adapter": "^1.6.0", + "@auth/prisma-adapter": "^2.11.1", "@heroicons/react": "^2.1.5", "@hookform/resolvers": "^3.9.0", + "@mistcash/config": "^0.2.2", + "@mistcash/crypto": "^0.2.2", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "6.2.1", "@radix-ui/react-tooltip": "^1.1.8", @@ -59,7 +61,7 @@ "cavos-service-sdk": "^1.2.38", "daisyui": "^4.12.10", "eslint": "^8.57.0", - "eslint-config-next": "^14.2.4", + "eslint-config-next": "^16.0.3", "eslint-plugin-storybook": "^0.8.0", "framer-motion": "^11.3.30", "geist": "^1.3.0", @@ -81,7 +83,7 @@ "react-i18next": "^15.0.2", "resend": "^6.1.2", "server-only": "^0.0.1", - "starknet": "^6.11.0", + "starknet": "^8.9.0", "starknetkit": "^2.3.0", "storybook": "^8.3.0", "superjson": "^2.2.1", @@ -97,4 +99,4 @@ "ct3aMetadata": { "initVersion": "7.37.0" } -} +} \ No newline at end of file diff --git a/apps/web/public/locales/en/common.json b/apps/web/public/locales/en/common.json index f405e7bf..13efa5f5 100644 --- a/apps/web/public/locales/en/common.json +++ b/apps/web/public/locales/en/common.json @@ -331,7 +331,8 @@ "im_not_in_gam": "I'm not in GAM", "next": "Next", - "proceed_to_payment": "Proceed to Payment", + "proceed_to_payment": "Pay", + "proceed_to_payment_private": "Pay with privacy (Beta)", "processing_payment": "Processing Payment...", "review_your_order": "Review Your Order", "quantity": "Quantity", diff --git a/apps/web/src/app/_components/features/checkout/OrderReview.tsx b/apps/web/src/app/_components/features/checkout/OrderReview.tsx index 0853c6fc..0bb2fe0a 100644 --- a/apps/web/src/app/_components/features/checkout/OrderReview.tsx +++ b/apps/web/src/app/_components/features/checkout/OrderReview.tsx @@ -9,7 +9,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useCreateOrder } from "~/services/useCreateOrder"; +import { useCreateOrder, useCreateOrderMist } from "~/services/useCreateOrder"; import { cartItemsAtom, clearCartAtom, isCartOpenAtom } from "~/store/cartAtom"; import type { CartItem } from "~/store/cartAtom"; import { api } from "~/trpc/react"; @@ -70,6 +70,7 @@ export default function OrderReview({ const [purchasedItems, setPurchasedItems] = useState([]); const router = useRouter(); const createOrder = useCreateOrder(); + const createOrderMist = useCreateOrderMist(); // Get current cart const { data: cart } = api.cart.getUserCart.useQuery(); @@ -147,6 +148,40 @@ export default function OrderReview({ setIsProcessing(false); }; + const handleProceedToPaymentSecure = async () => { + try { + setIsProcessing(true); + setError(null); + + if (!cart?.id) { + throw new Error("Cart not found"); + } + + // Store cart items before clearing (for confirmation page) + setPurchasedItems([...cartItems]); + + // Create order in the database + await createOrderMist.mutateAsync({ + cartId: cart.id, + paymentToken: selectedCurrency as PaymentToken, + deliveryAddress: deliveryMethod === "home" ? deliveryAddress : undefined, + deliveryMethod: deliveryMethod, + }); + + // Clear the cart and close the cart sidebar + clearCart(); + setIsCartOpen(false); + + // Show confirmation (don't redirect immediately - let user see success page) + setShowConfirmation(true); + } catch (err) { + console.error("Payment error:", err); + const errorMessage = err instanceof Error ? err.message : "Failed to process payment"; + setError(errorMessage); + } + setIsProcessing(false); + }; + // Check if error is related to insufficient balance const isInsufficientBalance = error && /insufficient.*balance/i.test(error); @@ -314,9 +349,17 @@ export default function OrderReview({ {t("change_currency")} + +