diff --git a/README.md b/README.md index cd7f526..db3366e 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,101 @@ -## Payment Splitter +## Splitter Tab -## Description +

+
+ logo +
+

+

Group Splitting Payment App.

+

+ + GitHub stars + + + EasyA + + +

+ +--- + +Make group payments faster, and effortless, and delay worries about extending funds with just a few clicks. + +The Payment Splitter dApp is designed to simplify group payments and make sharing costs as easy as possible for anyone, whether splitting a bill, organizing a group gift, or settling expenses after a trip. With a sleek mobile-first design, the app allows users to interact intuitively and ensures that everyone can quickly settle up. + +The app offers multiple features for better usability, including user-friendly payment splitting, expense management, and automatic cost calculations. + +```mermaid +graph TD + A[User] -->|Interacts with| B[React Mobile-first dApp] + B -->|Sends requests to| D[Stellar Network] + D -->|Executes| E[Smart Contract] + E -->|Manages| F[Payment Splitting Logic] + F -->|Sends| G[Stablecoins] + G -->|To| H[Recipients] + + subgraph Frontend + B -->|State Management| I[React Hooks/Context] + B -->|UI Components| J[shadcn/ui] + B -->|Auth| K[Passkeys / Freighter] + end + + subgraph Stellar + D -->|Account Management| N[Stellar Accounts] + D -->|Transaction Handling| O[Stellar SDK] + end +``` ### Links +- [Live Demo](https://splittertab.vercel.app/) +- [Demo](https://www.loom.com/share/930ee89b5d7a4f12b8a0923dd61c431c?sid=c239ff8f-8ca2-4d30-b24b-99ee91baf506)) +- [Pitch Deck](https://www.canva.com/design/DAGTabHuIc4/BDLopYPbazWElCHx-Q-Z7A/edit?utm_content=DAGTabHuIc4&utm_campaign=designshare&utm_medium=link2&utm_source=sharebutton +) + ### Problem -### Solution +While blockchain technology is becoming increasingly popular in the finance sector and among software engineers, there remains a significant divide between everyday users and those more experienced with cryptocurrencies. The payment splitting app aims to bridge this gap by offering a simple, intuitive tool that can be adopted by a mainstream audience. -### Impact +One key challenge faced by users in big groups or those in a rush is the time and effort required to manually calculate and track individual expenses. This app solves that problem by offering fast and efficient payment splitting without needing detailed input from each participant. Users can quickly divide costs, even for large groups, without the hassle of collecting too much information. -## Features +The app’s streamlined process allows users to split expenses and execute payments on the go, making it perfect for situations where time is of the essence, like dining with friends, travelling, or group activities. By leveraging blockchain technology and automation, the app ensures smooth, secure transactions while minimizing the complexity for everyday users. + +### Images + +Screenshot 2024-10-13 at 11 50 51 -- +image +image + +image + +Screenshot 2024-10-13 at 11 53 27 + + +## Features + +- Payment Splitting (By Full, By Item, Evenly) +- Lending and Borrowing Compatibility +- Creating Groups/Expenses +- Receipt OCR system to extract the specific costs +- Simple and intuitive UI thanks to Passkeys Integration + ## Tech Stack -- +- **Dapp** - React + Vite + shadcn/ui +- **Web3** - Stellar + Soroban smart contracts +- **Auth** - Passkeys / Freigther + + ## Tech Team + +- @nevvdevv +- **NOÉ DE LARMINAT** - noe.de.91@gmail.com | @NodeLarminat +- **CINDY CIENIEK** - cindycieniek@gmail.com | @ciencck28593 + +## Getting Started -### Getting Started +- Install dependenciex - `pnpm i` +- Build smart Soroban smart contracts and deploy to chosen network(optional) +- Copy content of `.env.example` to `.env` and fill it out with your variables +- Start dapp with command `pnpm run dev` diff --git a/contracts/README b/contracts/README index 40469fe..fb9aa8d 100644 --- a/contracts/README +++ b/contracts/README @@ -20,3 +20,12 @@ stellar network add \ --global testnet \ --rpc-url https://soroban-testnet.stellar.org:443 \ --network-passphrase "Test SDF Network ; September 2015" + + +Contracts deployed: +Testnet: + Upload: https://futurenet.stellarchain.io/transactions/56678a618ce44df498de9a2e433ce9b2ca310a8fff7d2ca4ae0493964c1dd36e + Create: https://futurenet.stellarchain.io/transactions/415a8ec3f1d16425f4ecfc1539749b6c9c2ea564259adaacc2f04942aa1c9ca3 +Futurnet: + Upload: https://testnet.stellarchain.io/transactions/0c429f3f64a4a0a6064b7c231e6078a9c0b0a70d5e272970543ccf24b56e945d + Create: https://testnet.stellarchain.io/transactions/ef96c6e47417ecec40743ccf7c4f5b383afa42e09ee1c3689d97997bcd630025 diff --git a/contracts/StellarPay.rs b/contracts/StellarPay.rs new file mode 100644 index 0000000..86ab717 --- /dev/null +++ b/contracts/StellarPay.rs @@ -0,0 +1,210 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Vec, Map, Symbol, log, panic_with_error, contracterror}; + +#[derive(Clone, Debug)] +#[contracttype] +pub struct Group { + group_id: u64, + owner: Address, + total_amount: u64, + members: Vec, +} + +#[derive(Clone)] +#[contracttype] +pub struct Member { + user_id: Symbol, + address: Address, + group_balances: Map, +} + +#[derive(Clone)] +#[contracttype] +pub struct Transaction { + user_id: Symbol, + group_id: u64, + amount: u64, + proof: Symbol, // IPFS link + approvals: Vec, +} + +#[derive(Clone, PartialEq, Eq)] +#[contracttype] +pub enum DataKey { + Group(u64), + Member(Symbol), + Transaction(u64), + LastGroupId, + LastTransactionId, + GroupList, // List of all groups +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + GroupAlreadyExists = 1, + GroupNotFound = 2, + Unauthorized = 3, + MemberAlreadyExists = 4, + MemberNotFound = 5, + UserIdAlreadyExists = 6, + GroupCreationFailed = 7, +} + +#[contract] +pub struct MappingContract; + +#[contractimpl] +impl MappingContract { + pub fn create_group(env: &Env, owner: Address) -> u64 { + log!(env, "Entering create_group function"); + log!(env, "Owner address: {:?}", owner); + + let group_id = Self::get_next_group_id(env); + log!(env, "Generated new group_id: {}", group_id); + + let owner_symbol = Symbol::short("owner"); + log!(env, "Created owner symbol: {:?}", owner_symbol); + + let members = Vec::from_array(env, [owner_symbol]); + log!(env, "Created members vector: {:?}", members); + + let group = Group { + group_id, + owner: owner.clone(), + total_amount: 0, + members: Vec::new(env), + }; + log!(env, "Created new group: {:?}", group); + + // Store the individual group + env.storage().persistent().set(&DataKey::Group(group_id), &group); + log!(env, "Stored individual group in persistent storage"); + + // Update the list of all groups + let mut group_list = env.storage().persistent().get::>(&DataKey::GroupList) + .unwrap_or_else(|| { + log!(env, "Group list not found, creating new list"); + Vec::new(env) + }); + log!(env, "Current group list size: {}", group_list.len()); + + group_list.push_back(group); + log!(env, "Added new group to list. New size: {}", group_list.len()); + + env.storage().persistent().set(&DataKey::GroupList, &group_list); + log!(env, "Stored updated group list in persistent storage"); + + log!(env, "Exiting create_group function. Returning group_id: {}", group_id); + group_id + } + + pub fn create_member(env: &Env, user_id: Symbol, address: Address) { + if env.storage().persistent().has(&DataKey::Member(user_id.clone())) { + panic_with_error!(env, Error::UserIdAlreadyExists); + } + let member = Member { + user_id: user_id.clone(), + address, + group_balances: Map::new(env), + }; + env.storage().persistent().set(&DataKey::Member(user_id), &member); + } + + pub fn add_member_to_group(env: &Env, group_id: u64, user_id: Symbol, caller: Address) { + let mut group = env.storage().persistent().get::(&DataKey::Group(group_id)) + .unwrap_or_else(|| panic_with_error!(env, Error::GroupNotFound)); + + if group.owner != caller { + panic_with_error!(env, Error::Unauthorized); + } + + if group.members.contains(&user_id) { + panic_with_error!(env, Error::MemberAlreadyExists); + } + + group.members.push_back(user_id.clone()); + env.storage().persistent().set(&DataKey::Group(group_id), &group); + + let mut member = env.storage().persistent().get::(&DataKey::Member(user_id.clone())) + .unwrap_or_else(|| panic_with_error!(env, Error::MemberNotFound)); + member.group_balances.set(group_id, 0); + env.storage().persistent().set(&DataKey::Member(user_id), &member); + } + + pub fn add_transaction(env: &Env, user_id: Symbol, group_id: u64, amount: u64, proof: Symbol) -> u64 { + let tx_id = Self::get_next_transaction_id(env); + let transaction = Transaction { + user_id: user_id.clone(), + group_id, + amount, + proof, + approvals: Vec::new(env), + }; + env.storage().persistent().set(&DataKey::Transaction(tx_id), &transaction); + + // Update group total amount + let mut group = env.storage().persistent().get::(&DataKey::Group(group_id)) + .unwrap_or_else(|| panic_with_error!(env, Error::GroupNotFound)); + group.total_amount += amount; + env.storage().persistent().set(&DataKey::Group(group_id), &group); + + // Update member's balance in the group + let mut member = env.storage().persistent().get::(&DataKey::Member(user_id.clone())) + .unwrap_or_else(|| panic_with_error!(env, Error::MemberNotFound)); + let new_balance = member.group_balances.get(group_id).unwrap_or(0) + amount; + member.group_balances.set(group_id, new_balance); + env.storage().persistent().set(&DataKey::Member(user_id.clone()), &member); + + tx_id + } + + pub fn approve_transaction(env: &Env, tx_id: u64, approver_id: Symbol) { + let mut transaction = env.storage().persistent().get::(&DataKey::Transaction(tx_id)) + .unwrap_or_else(|| panic_with_error!(env, Error::GroupNotFound)); + + let group = env.storage().persistent().get::(&DataKey::Group(transaction.group_id)) + .unwrap_or_else(|| panic_with_error!(env, Error::GroupNotFound)); + + if !group.members.contains(&approver_id) { + panic_with_error!(env, Error::Unauthorized); + } + + if !transaction.approvals.contains(&approver_id) { + transaction.approvals.push_back(approver_id); + env.storage().persistent().set(&DataKey::Transaction(tx_id), &transaction); + } + } + + pub fn get_all_groups(env: &Env) -> Vec { + env.storage().persistent().get::>(&DataKey::GroupList) + .unwrap_or_else(|| Vec::new(env)) + } + + fn get_next_group_id(env: &Env) -> u64 { + log!(env, "Entering get_next_group_id function"); + + let last_id = env.storage().persistent().get::(&DataKey::LastGroupId).unwrap_or(0); + log!(env, "Last group ID: {}", last_id); + + let new_id = last_id.checked_add(1).unwrap_or_else(|| { + log!(env, "Group ID overflow occurred"); + panic_with_error!(env, Error::GroupCreationFailed); + }); + log!(env, "New group ID: {}", new_id); + + env.storage().persistent().set(&DataKey::LastGroupId, &new_id); + log!(env, "Stored new last group ID in persistent storage"); + + log!(env, "Exiting get_next_group_id function. Returning new_id: {}", new_id); + new_id + } + + fn get_next_transaction_id(env: &Env) -> u64 { + let last_id = env.storage().persistent().get::(&DataKey::LastTransactionId).unwrap_or(0); + let new_id = last_id.checked_add(1).unwrap_or_else(|| panic_with_error!(env, Error::GroupCreationFailed)); + env.storage().persistent().set(&DataKey::LastTransactionId, &new_id); + new_id + } +} \ No newline at end of file diff --git a/contracts/StellarPay2.rs b/contracts/StellarPay2.rs new file mode 100644 index 0000000..074e9e3 --- /dev/null +++ b/contracts/StellarPay2.rs @@ -0,0 +1,115 @@ +#![no_std] +use soroban_sdk::{contract, contracttype, contractimpl, Address, Env, Map, String, Symbol, Vec, Error, log}; + +#[contracttype] +pub enum DataKey { + Member(Address), + Group(Symbol), + Transaction(Address), // Modified to only use Address + AllGroupIds, // New key for storing the list of all group IDs +} + +#[contracttype] +pub struct Member { + nickname: String, + address: Address, +} + +#[contracttype] +pub struct Group { + group_id: Symbol, + owner: Address, + members: Map, +} + +#[contracttype] +pub struct Transaction { + user_id: Address, + group_id: Symbol, + amounts: Map, + proof: String, + approvals: Vec
, +} + +#[contract] +pub struct Contract; + +#[contractimpl] +impl Contract { + // Member functions + pub fn set_member(env: Env, address: Address, nickname: String) { + address.require_auth(); + let member = Member { nickname, address: address.clone() }; + env.storage().instance().set(&DataKey::Member(address), &member); + } + + pub fn get_member(env: Env, address: Address) -> Result { + env.storage().instance().get(&DataKey::Member(address)).ok_or_else(|| Error::from_contract_error(1)) + } + + // Group functions + pub fn set_group(env: Env, group_id: Symbol, owner: Address, members: Map) { + let group = Group { group_id: group_id.clone(), owner, members }; + env.storage().instance().set(&DataKey::Group(group_id.clone()), &group); + + // Update the list of all group IDs + let mut all_group_ids: Vec = env.storage().instance().get(&DataKey::AllGroupIds).unwrap_or_else(|| { + log!(&env, "Initializing new all_group_ids vector"); + Vec::new(&env) + }); + + if !all_group_ids.contains(&group_id) { + log!(&env, "Adding new group_id to all_group_ids: {:?}", group_id); + all_group_ids.push_back(group_id.clone()); + env.storage().instance().set(&DataKey::AllGroupIds, &all_group_ids); + } + + log!(&env, "Current all_group_ids: {:?}", all_group_ids); + } + pub fn get_all_group_ids(env: Env) -> Vec { + let all_group_ids = env.storage().instance().get(&DataKey::AllGroupIds).unwrap_or_else(|| Vec::new(&env)); + log!(&env, "Retrieved all_group_ids: {:?}", all_group_ids); + all_group_ids + } + + pub fn get_group(env: Env, group_id: Symbol) -> Result { + env.storage().instance().get(&DataKey::Group(group_id)).ok_or_else(|| Error::from_contract_error(2)) + } + + pub fn add_group_member(env: Env, group_id: Symbol, member_address: Address, balance: i128) -> Result<(), Error> { + let mut group: Group = env.storage().instance().get(&DataKey::Group(group_id.clone())).ok_or_else(|| Error::from_contract_error(2))?; + group.members.set(member_address, balance); + env.storage().instance().set(&DataKey::Group(group_id), &group); + Ok(()) + } + + pub fn update_group_member_balance(env: Env, group_id: Symbol, member_address: Address, new_balance: i128) -> Result<(), Error> { + let mut group: Group = env.storage().instance().get(&DataKey::Group(group_id.clone())).ok_or_else(|| Error::from_contract_error(2))?; + group.members.set(member_address, new_balance); + env.storage().instance().set(&DataKey::Group(group_id), &group); + Ok(()) + } + + // Transaction functions + pub fn set_transaction(env: Env, user_id: Address, group_id: Symbol, amounts: Map, proof: String) { + let transaction = Transaction { + user_id: user_id.clone(), + group_id, + amounts, + proof, + approvals: Vec::new(&env), + }; + env.storage().instance().set(&DataKey::Transaction(user_id), &transaction); + } + + pub fn get_transaction(env: Env, user_id: Address) -> Result { + env.storage().instance().get(&DataKey::Transaction(user_id)).ok_or_else(|| Error::from_contract_error(3)) + } + + pub fn add_approval(env: Env, user_id: Address, approver: Address) -> Result<(), Error> { + let mut transaction: Transaction = env.storage().instance().get(&DataKey::Transaction(user_id.clone())).ok_or_else(|| Error::from_contract_error(3))?; + transaction.approvals.push_back(approver); + env.storage().instance().set(&DataKey::Transaction(user_id), &transaction); + Ok(()) + } +} diff --git a/dapp/.gitignore b/dapp/.gitignore index 3b0b403..f8dd907 100644 --- a/dapp/.gitignore +++ b/dapp/.gitignore @@ -23,4 +23,5 @@ dist-ssr *.sln *.sw? -.env \ No newline at end of file +.env +.vercel diff --git a/dapp/env.example b/dapp/env.example new file mode 100644 index 0000000..dfbb632 --- /dev/null +++ b/dapp/env.example @@ -0,0 +1,9 @@ +VITE_PUBLIC_networkPassphrase="Test SDF Future Network ; October 2022" +VITE_PUBLIC_chickenVsEggContractId="CADVRBYFMG6RIRFP6VOAATDCZERWPCNGFKQ7KYPD3D4FAI7VZ7WFNDSB" +VITE_PUBLIC_factoryContractId="CCZWIOWKT4WGJQHWZFF7ARCQJFVWRXPOKG4WGY6DOZ72OHZEMKXAEGRO" +VITE_PUBLIC_accountSecp256r1ContractWasm="23d8e1fbdb0bb903815feb7d07b675db98b5376feedab056aab61910d41e80c1" +VITE_PUBLIC_rpcUrl="https://rpc-futurenet.stellar.org/" +VITE_PUBLIC_horizonUrl="https://horizon-futurenet.stellar.org/" +VITE_PUBLIC_INFURA_ID="3bf6a06c7cbf433ea3d394dde34be40e" +VITE_PUBLIC_INFURA_SECRET="87e3d18c222f4ae2b8ea2207fc2cc9bc" +VITE_PUBLIC_IPFS_GATEWAY="" \ No newline at end of file diff --git a/dapp/index.html b/dapp/index.html index 6857097..b6c7f3a 100644 --- a/dapp/index.html +++ b/dapp/index.html @@ -4,7 +4,7 @@ - SplitPay + SplitterTab
diff --git a/dapp/package.json b/dapp/package.json index cce9f06..46e2a7d 100644 --- a/dapp/package.json +++ b/dapp/package.json @@ -13,14 +13,21 @@ "@capacitor/core": "^6.1.2", "@capacitor/share": "^6.0.2", "@creit.tech/stellar-wallets-kit": "^1.2.3", + "@darkedges/capacitor-native-webauthn": "^0.0.4", + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", + "@simplewebauthn/browser": "^10.0.0", + "@simplewebauthn/types": "^10.0.0", "@stellar/stellar-sdk": "^12.3.0", + "axios": "^1.7.7", "base64url": "^3.0.1", "bigint-conversion": "^2.4.3", "cbor-x": "^1.6.0", diff --git a/dapp/pnpm-lock.yaml b/dapp/pnpm-lock.yaml index 924053d..e94714b 100644 --- a/dapp/pnpm-lock.yaml +++ b/dapp/pnpm-lock.yaml @@ -17,15 +17,27 @@ importers: '@creit.tech/stellar-wallets-kit': specifier: ^1.2.3 version: 1.2.3(@types/react@18.3.11)(react@18.3.1) + '@darkedges/capacitor-native-webauthn': + specifier: ^0.0.4 + version: 0.0.4(@capacitor/core@6.1.2) + '@radix-ui/react-avatar': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-checkbox': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-icons': specifier: ^1.3.0 version: 1.3.0(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.0 version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.0 + version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-radio-group': specifier: ^1.2.1 version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -38,9 +50,18 @@ importers: '@radix-ui/react-toast': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@simplewebauthn/browser': + specifier: ^10.0.0 + version: 10.0.0 + '@simplewebauthn/types': + specifier: ^10.0.0 + version: 10.0.0 '@stellar/stellar-sdk': specifier: ^12.3.0 version: 12.3.0 + axios: + specifier: ^1.7.7 + version: 1.7.7 base64url: specifier: ^3.0.1 version: 3.0.1 @@ -676,6 +697,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.1': + resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.1.2': resolution: {integrity: sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==} peerDependencies: @@ -729,6 +763,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.2': + resolution: {integrity: sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.0': resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} peerDependencies: @@ -852,6 +899,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.0': + resolution: {integrity: sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.2.1': resolution: {integrity: sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==} peerDependencies: @@ -1076,9 +1136,15 @@ packages: cpu: [x64] os: [win32] + '@simplewebauthn/browser@10.0.0': + resolution: {integrity: sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==} + '@simplewebauthn/browser@9.0.1': resolution: {integrity: sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==} + '@simplewebauthn/types@10.0.0': + resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==} + '@simplewebauthn/types@9.0.1': resolution: {integrity: sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==} @@ -2224,14 +2290,12 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} -<<<<<<< HEAD - shallow-equal@3.1.0: - resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==} -======= sha.js@2.4.11: resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} hasBin: true ->>>>>>> ed2751b (Added the contracts env) + + shallow-equal@3.1.0: + resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -3118,6 +3182,18 @@ snapshots: '@types/react': 18.3.11 '@types/react-dom': 18.3.0 + '@radix-ui/react-avatar@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.11)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + '@radix-ui/react-checkbox@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -3164,6 +3240,28 @@ snapshots: optionalDependencies: '@types/react': 18.3.11 + '@radix-ui/react-dialog@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.11)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.0(@types/react@18.3.11)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.11)(react@18.3.1)': dependencies: react: 18.3.1 @@ -3267,6 +3365,16 @@ snapshots: '@types/react': 18.3.11 '@types/react-dom': 18.3.0 + '@radix-ui/react-progress@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.0(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + '@radix-ui/react-radio-group@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -3465,10 +3573,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.24.0': optional: true + '@simplewebauthn/browser@10.0.0': + dependencies: + '@simplewebauthn/types': 10.0.0 + '@simplewebauthn/browser@9.0.1': dependencies: '@simplewebauthn/types': 9.0.1 + '@simplewebauthn/types@10.0.0': {} + '@simplewebauthn/types@9.0.1': {} '@stablelib/aead@1.0.1': {} @@ -4808,14 +4922,12 @@ snapshots: set-blocking@2.0.0: {} -<<<<<<< HEAD - shallow-equal@3.1.0: {} -======= sha.js@2.4.11: dependencies: inherits: 2.0.4 safe-buffer: 5.2.1 ->>>>>>> ed2751b (Added the contracts env) + + shallow-equal@3.1.0: {} shebang-command@2.0.0: dependencies: diff --git a/dapp/src/App.tsx b/dapp/src/App.tsx index 73c136c..7e643f7 100644 --- a/dapp/src/App.tsx +++ b/dapp/src/App.tsx @@ -5,8 +5,13 @@ import Create from "./pages/Create"; import SoroPass from "./pages/Auth"; import { Toaster } from "./components/ui/toaster"; import Layout from "./components/Layout"; +import Landing from "./pages/Landing"; +import { useAuth } from "./hooks/use-auth"; function App() { + const { isAuthenticated, userAddress } = useAuth(); + if (!isAuthenticated && !userAddress) return ; + return ( <> @@ -17,6 +22,7 @@ function App() { } /> } /> } /> + {/* } /> */} diff --git a/dapp/src/components/Layout.tsx b/dapp/src/components/Layout.tsx index fee70bc..5686cb8 100644 --- a/dapp/src/components/Layout.tsx +++ b/dapp/src/components/Layout.tsx @@ -1,43 +1,43 @@ -import React, { useEffect, useState } from "react"; +// @ts-nocheck +import { useEffect, useState } from "react"; import { useMediaQuery } from "react-responsive"; -import { Home, Plus, User, Wallet } from "lucide-react"; +import { Home, Plus, User } from "lucide-react"; import { Button } from "@/components/ui/button"; import { motion } from "framer-motion"; import { useStellarWallets } from "@/context/StellarWalletsContext"; import { ISupportedWallet } from "@creit.tech/stellar-wallets-kit"; -import { truncateStr } from "@/utils"; -import { Link } from "react-router-dom"; +// import { truncateStr } from "@/utils"; +import { Link, useLocation } from "react-router-dom"; const Logo = () => (
-

SplitPay

+

SplitterTab

); const NavItem = ({ icon: Icon, label, path, isActive, isMobile, onClick }) => { + const location = useLocation(); return ( - + +
+ {/* */} +
+ Powered By Stellar +
+
); }; diff --git a/dapp/src/components/ui/avatar.tsx b/dapp/src/components/ui/avatar.tsx new file mode 100644 index 0000000..991f56e --- /dev/null +++ b/dapp/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/dapp/src/components/ui/dialog.tsx b/dapp/src/components/ui/dialog.tsx new file mode 100644 index 0000000..5d16351 --- /dev/null +++ b/dapp/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/dapp/src/components/ui/progress.tsx b/dapp/src/components/ui/progress.tsx new file mode 100644 index 0000000..3fd47ad --- /dev/null +++ b/dapp/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/dapp/src/constants.ts b/dapp/src/constants.ts new file mode 100644 index 0000000..47b17e5 --- /dev/null +++ b/dapp/src/constants.ts @@ -0,0 +1,5 @@ +export const splitterContract = + "CBJJIHPMWCEEPCN4LMGOOR6XPQXOMBF4X6DMNRNQTJOPEVBD43U3PZF5"; + +export const splitterLogic = + "CBJJIHPMWCEEPCN4LMGOOR6XPQXOMBF4X6DMNRNQTJOPEVBD43U3PZF5"; diff --git a/dapp/src/context/StellarWalletsContext.tsx b/dapp/src/context/StellarWalletsContext.tsx index 86336e6..53e7eb4 100644 --- a/dapp/src/context/StellarWalletsContext.tsx +++ b/dapp/src/context/StellarWalletsContext.tsx @@ -39,14 +39,14 @@ export const useStellarWallets = () => { // Example usage in a component: const ExampleComponent: React.FC = () => { - const stellarWalletsKit = useStellarWallets(); + // const stellarWalletsKit = useStellarWallets(); const handleConnect = async () => { try { await kit.openModal({ onWalletSelected: async (option: ISupportedWallet) => { kit.setWallet(option.id); - const { address } = await kit.getAddress(); + // const { address } = await kit.getAddress(); }, }); } catch (error) { diff --git a/dapp/src/hooks/use-auth.tsx b/dapp/src/hooks/use-auth.tsx new file mode 100644 index 0000000..2d1aa17 --- /dev/null +++ b/dapp/src/hooks/use-auth.tsx @@ -0,0 +1,189 @@ +import React, { + createContext, + useState, + useContext, + useCallback, + useEffect, + ReactNode, +} from "react"; +import { WebAuthn } from "@darkedges/capacitor-native-webauthn"; +import { Capacitor } from "@capacitor/core"; +import base64url from "base64url"; +import { Keypair, Horizon } from "@stellar/stellar-sdk"; +import { getPublicKeys } from "../lib/webauthn"; +import { handleDeploy } from "../lib/deploy"; + +interface Connection { + status: "connected" | "disconnected"; + network: "testnet" | "public" | null; +} + +interface AuthContextType { + isAuthenticated: boolean; + deployee: string | null; + bundlerKey: Keypair | null; + connection: Connection; + userAddress: string | null; + register: () => Promise; + signIn: () => Promise; + signOut: () => void; + initializeBundler: () => Promise; +} + +const AuthContext = createContext(undefined); + +const PUBLIC_horizonUrl = import.meta.env.VITE_PUBLIC_horizonUrl; + +export const AuthProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [deployee, setDeployee] = useState(null); + const [bundlerKey, setBundlerKey] = useState(null); + const [connection, setConnection] = useState({ + status: "disconnected", + network: null, + }); + const [userAddress, setUserAddress] = useState(null); + + const initializeBundler = useCallback(async () => { + if (localStorage.getItem("sp:bundler")) { + const storedBundlerKey = Keypair.fromSecret( + localStorage.getItem("sp:bundler")! + ); + setBundlerKey(storedBundlerKey); + setUserAddress(storedBundlerKey.publicKey()); + } else { + const newBundlerKey = Keypair.random(); + setBundlerKey(newBundlerKey); + setUserAddress(newBundlerKey.publicKey()); + localStorage.setItem("sp:bundler", newBundlerKey.secret()); + + const horizon = new Horizon.Server(PUBLIC_horizonUrl!); + try { + await horizon.friendbot(newBundlerKey.publicKey()).call(); + console.log("New bundler account funded successfully"); + } catch (error) { + console.error("Error funding new bundler account:", error); + } + } + }, []); + + useEffect(() => { + initializeBundler(); + + const storedDeployee = localStorage.getItem("sp:deployee"); + if (storedDeployee) { + setDeployee(storedDeployee); + setIsAuthenticated(true); + setConnection({ status: "connected", network: "testnet" }); + } + }, [initializeBundler]); + + const register = useCallback(async () => { + try { + const isWebAuthnAvailable = await WebAuthn.isWebAuthnAvailable(); + if (!isWebAuthnAvailable.value) { + throw new Error("WebAuthn is not available on this device"); + } + + const registerRes = await WebAuthn.startRegistration({ + challenge: base64url(Buffer.from("createchallenge")), + rp: { + id: Capacitor.isNativePlatform() + ? "passkey.sorobanbyexample.org" + : undefined, + name: "SoroPass", + }, + user: { + id: base64url("Soroban Test"), + name: "Soroban Test", + displayName: "Soroban Test", + }, + authenticatorSelection: { + requireResidentKey: false, + residentKey: + Capacitor.getPlatform() === "android" ? "preferred" : "discouraged", + userVerification: "discouraged", + }, + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + attestation: "none", + }); + + localStorage.setItem("sp:id", registerRes.id); + + if (bundlerKey) { + const { contractSalt, publicKey } = await getPublicKeys(registerRes); + const newDeployee = await handleDeploy( + bundlerKey, + contractSalt, + publicKey! + ); + setDeployee(newDeployee); + localStorage.setItem("sp:deployee", newDeployee); + setIsAuthenticated(true); + setConnection({ status: "connected", network: "testnet" }); + } else { + throw new Error("BundlerKey is not initialized"); + } + } catch (error) { + console.error("Registration error:", error); + throw error; + } + }, [bundlerKey]); + + const signIn = useCallback(async () => { + try { + const signInRes = await WebAuthn.startAuthentication({ + challenge: base64url("createchallenge"), + rpId: Capacitor.isNativePlatform() + ? "passkey.sorobanbyexample.org" + : undefined, + userVerification: "discouraged", + }); + + localStorage.setItem("sp:id", signInRes.id); + + // + + setIsAuthenticated(true); + setConnection({ status: "connected", network: "testnet" }); + } catch (error) { + console.error("Sign in error:", error); + throw error; + } + }, []); + + const signOut = useCallback(() => { + localStorage.removeItem("sp:id"); + localStorage.removeItem("sp:deployee"); + setIsAuthenticated(false); + setDeployee(null); + setConnection({ status: "disconnected", network: null }); + setUserAddress(null); + }, []); + + const contextValue: AuthContextType = { + isAuthenticated, + deployee, + bundlerKey, + connection, + userAddress, + register, + signIn, + signOut, + initializeBundler, + }; + + return ( + {children} + ); +}; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/dapp/src/main.tsx b/dapp/src/main.tsx index 415449b..ce1a77b 100644 --- a/dapp/src/main.tsx +++ b/dapp/src/main.tsx @@ -3,11 +3,14 @@ import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; import { StellarWalletsProvider } from "./context/StellarWalletsContext.tsx"; +import { AuthProvider } from "./hooks/use-auth.tsx"; ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); diff --git a/dapp/src/pages/Auth.tsx b/dapp/src/pages/Auth.tsx index 1c44386..b5dcf8c 100644 --- a/dapp/src/pages/Auth.tsx +++ b/dapp/src/pages/Auth.tsx @@ -1,9 +1,20 @@ -import React, { useState, useEffect, useCallback } from 'react'; +// @ts-nocheck +import React, { useState, useEffect, useCallback } from "react"; import { WebAuthn } from "@darkedges/capacitor-native-webauthn"; -import { Buffer } from 'buffer'; +import { Buffer } from "buffer"; import base64url from "base64url"; import { Capacitor } from "@capacitor/core"; -import { Horizon, Keypair } from "@stellar/stellar-sdk"; +import { + Horizon, + Keypair, + xdr, + Address, + Operation, + TransactionBuilder, + Account, + scValToNative, + nativeToScVal, +} from "@stellar/stellar-sdk"; import { Share } from "@capacitor/share"; import { motion, AnimatePresence } from "framer-motion"; @@ -16,8 +27,9 @@ import { handleDeploy } from "../lib/deploy"; import { handleVoteBuild } from "../lib/vote_build"; import { handleVoteSend } from "../lib/vote_send"; import { getVotes } from "../lib/get_votes"; +import { contractRead } from "@/utils"; -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { window.Buffer = Buffer; } @@ -52,7 +64,7 @@ const SoroPass: React.FC = () => { setTimeout(() => setStep(1), 500); const dotInterval = setInterval(() => { - setDots(prev => prev.length === 3 ? "" : prev + "."); + setDots((prev) => (prev.length === 3 ? "" : prev + ".")); }, 500); const voteInterval = setInterval(() => onVotes(), 1500); @@ -64,10 +76,12 @@ const SoroPass: React.FC = () => { setBundlerKey(newBundlerKey); localStorage.setItem("sp:bundler", newBundlerKey.secret()); - const horizon = new Horizon.Server(import.meta.env.PUBLIC_horizonUrl!); + const horizon = new Horizon.Server( + import.meta.env.VITE_PUBLIC_horizonUrl! + ); horizon.friendbot(newBundlerKey.publicKey()).call(); } - + console.log("is local storage", localStorage); if (localStorage.hasOwnProperty("sp:deployee")) { @@ -81,65 +95,77 @@ const SoroPass: React.FC = () => { }; }, []); - const onRegister = useCallback(async (type?: "signin") => { - if (!type && deployee) { - setStep(prev => prev + 1); - return; - } - - try { - setLoadingRegister(true); - - const isWebAuthnAvailable = await WebAuthn.isWebAuthnAvailable(); - if (!isWebAuthnAvailable.value) { - throw new Error("WebAuthn is not available on this device"); + const onRegister = useCallback( + async (type?: "signin") => { + if (!type && deployee) { + setStep((prev) => prev + 1); + return; } - - let registerRes; - if (type === "signin") { - registerRes = await WebAuthn.startAuthentication({ - challenge: base64url("createchallenge"), - rpId: Capacitor.isNativePlatform() ? "passkey.sorobanbyexample.org" : undefined, - userVerification: "discouraged", - }); - } else { - registerRes = await WebAuthn.startRegistration({ - challenge: base64url(Buffer.from("createchallenge")), - rp: { - id: Capacitor.isNativePlatform() ? "passkey.sorobanbyexample.org" : undefined, - name: "SoroPass", - }, - user: { - id: base64url("Soroban Test"), - name: "Soroban Test", - displayName: "Soroban Test", - }, - authenticatorSelection: { - requireResidentKey: false, - residentKey: Capacitor.getPlatform() === "android" ? "preferred" : "discouraged", + + try { + setLoadingRegister(true); + + const isWebAuthnAvailable = await WebAuthn.isWebAuthnAvailable(); + if (!isWebAuthnAvailable.value) { + throw new Error("WebAuthn is not available on this device"); + } + + let registerRes; + if (type === "signin") { + registerRes = await WebAuthn.startAuthentication({ + challenge: base64url("createchallenge"), + rpId: Capacitor.isNativePlatform() + ? "passkey.sorobanbyexample.org" + : undefined, userVerification: "discouraged", - }, - pubKeyCredParams: [{ alg: -7, type: "public-key" }], - attestation: "none", - }); + }); + } else { + registerRes = await WebAuthn.startRegistration({ + challenge: base64url(Buffer.from("createchallenge")), + rp: { + id: Capacitor.isNativePlatform() + ? "passkey.sorobanbyexample.org" + : undefined, + name: "SoroPass", + }, + user: { + id: base64url("Soroban Test"), + name: "Soroban Test", + displayName: "Soroban Test", + }, + authenticatorSelection: { + requireResidentKey: false, + residentKey: + Capacitor.getPlatform() === "android" + ? "preferred" + : "discouraged", + userVerification: "discouraged", + }, + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + attestation: "none", + }); + } + + localStorage.setItem("sp:id", registerRes.id); + + const { contractSalt, publicKey } = await getPublicKeys(registerRes); + const newDeployee = await handleDeploy( + bundlerKey!, + contractSalt, + publicKey! + ); + setDeployee(newDeployee); + console.log("Logged in as", newDeployee); + setStep((prev) => prev + 1); + } catch (error) { + console.error(error); + alert(JSON.stringify(error)); + } finally { + setLoadingRegister(false); } - - localStorage.setItem("sp:id", registerRes.id); - - const { contractSalt, publicKey } = await getPublicKeys(registerRes); - const newDeployee = await handleDeploy(bundlerKey!, contractSalt, publicKey!); - setDeployee(newDeployee); - console.log("Logged in as", newDeployee); - setStep(prev => prev + 1); - } catch (error) { - console.error(error); - alert(JSON.stringify(error)); - } finally { - setLoadingRegister(false); - } - }, [bundlerKey, deployee]); - - + }, + [bundlerKey, deployee] + ); const onSign = useCallback(async () => { try { @@ -148,7 +174,7 @@ const SoroPass: React.FC = () => { let { authTxn, authHash, lastLedger } = await handleVoteBuild( bundlerKey!, deployee!, - choice === "chicken", + choice === "chicken" ); const signRes = await WebAuthn.startAuthentication({ @@ -167,9 +193,14 @@ const SoroPass: React.FC = () => { userVerification: "discouraged", }); - const respone = await handleVoteSend(bundlerKey!, authTxn, lastLedger, signRes); + const respone = await handleVoteSend( + bundlerKey!, + authTxn, + lastLedger, + signRes + ); await onVotes(); - setStep(prev => prev + 1); + setStep((prev) => prev + 1); console.log("Vote sent", respone, "Signature", signRes); } catch (error) { console.error(error); @@ -181,15 +212,44 @@ const SoroPass: React.FC = () => { const onVotes = useCallback(async () => { if (bundlerKey && deployee) { + console.log( + "deployee ", + deployee, + `\n`, + xdr.ScVal.scvAddress( + Address.fromString( + "GAIKTILDUD5TXL2FY5F4RAPDTTLUVKIOW3YA3WXKQM5RBEFLSUPZIMCE" + ) + ), + `\n`, + xdr.ScVal.scvAddress( + // Address.fromString( + "GAIKTILDUD5TXL2FY5F4RAPDTTLUVKIOW3YA3WXKQM5RBEFLSUPZIMCE" + // ).toScAddress() + ), + `\n`, + xdr.ScVal.scvAddress(Address.fromString(deployee).toScAddress()) + ); + + const formattedAddress = nativeToScVal( + "GAIKTILDUD5TXL2FY5F4RAPDTTLUVKIOW3YA3WXKQM5RBEFLSUPZIMCE", + { type: "address" } + ); + + const name = await contractRead(bundlerKey, "get_member", [ + formattedAddress, + ]); + const decodedName = scValToNative(name.result?.retval!); + console.log("name ", name, " ", decodedName); const newVotes = await getVotes(bundlerKey, deployee); setVotes(newVotes); console.log("NewVote", newVotes); } }, [bundlerKey, deployee]); - const truncateAccount = (account: string) => { - return `${account.slice(0, 5)}...${account.slice(-5)}`; - }; + // const truncateAccount = (account: string) => { + // return `${account.slice(0, 5)}...${account.slice(-5)}`; + // }; const swipeHandler = (event: any) => { if (event.detail.direction === "right") goLeft(); @@ -197,11 +257,7 @@ const SoroPass: React.FC = () => { }; const tapHandler = (event: any) => { - if ( - !["div", "h1", "p"].includes( - event.detail.target.tagName.toLowerCase() - ) - ) + if (!["div", "h1", "p"].includes(event.detail.target.tagName.toLowerCase())) return; else if ( document.querySelector("#soropass")?.clientWidth! / 2 > @@ -212,7 +268,7 @@ const SoroPass: React.FC = () => { }; const goLeft = () => { - if (!(step <= 1)) setStep(prev => prev - 1); + if (!(step <= 1)) setStep((prev) => prev - 1); }; const goRight = () => { @@ -224,54 +280,56 @@ const SoroPass: React.FC = () => { (step === 11 && !votes?.total_source_votes) ) ) - setStep(prev => prev + 1); + setStep((prev) => prev + 1); }; - const shareContent = async () => { - const { value } = await Share.canShare(); + // const shareContent = async () => { + // const { value } = await Share.canShare(); - if (value) { - await Share.share({ - title: "Share SoroPass", - text: "Check out this blockchain experience powered by your face or fingers!", - url: "https://passkey.sorobanbyexample.org/", - dialogTitle: `${choice === "chicken" ? 'Chicken 🐔' : 'Egg 🥚'} people unite!`, - }); - } else { - window.open( - `https://twitter.com/intent/tweet?text=${encodeURIComponent("Check out this blockchain experience powered by your face or fingers!")}&url=${encodeURIComponent("https://passkey.sorobanbyexample.org/")}`, - ); - } - }; + // if (value) { + // await Share.share({ + // title: "Share SoroPass", + // text: "Check out this blockchain experience powered by your face or fingers!", + // url: "https://passkey.sorobanbyexample.org/", + // dialogTitle: `${ + // choice === "chicken" ? "Chicken 🐔" : "Egg 🥚" + // } people unite!`, + // }); + // } else { + // window.open( + // `https://twitter.com/intent/tweet?text=${encodeURIComponent( + // "Check out this blockchain experience powered by your face or fingers!" + // )}&url=${encodeURIComponent("https://passkey.sorobanbyexample.org/")}` + // ); + // } + // }; - const resetAll = () => { - localStorage.removeItem("sp:id"); - localStorage.removeItem("sp:bundler"); - localStorage.removeItem("sp:deployee"); - window.location.reload(); - }; + // const resetAll = () => { + // localStorage.removeItem("sp:id"); + // localStorage.removeItem("sp:bundler"); + // localStorage.removeItem("sp:deployee"); + // window.location.reload(); + // }; return (
{ const touch = e.touches[0]; const startX = touch.clientX; const startY = touch.clientY; - + const handleTouchEnd = (e: TouchEvent) => { const touch = e.changedTouches[0]; const endX = touch.clientX; const endY = touch.clientY; - + const deltaX = endX - startX; const deltaY = endY - startY; - + if (Math.abs(deltaX) > Math.abs(deltaY)) { if (deltaX > 100) { swipeHandler({ detail: { direction: "right" } }); @@ -279,13 +337,15 @@ const SoroPass: React.FC = () => { swipeHandler({ detail: { direction: "left" } }); } } - - document.removeEventListener('touchend', handleTouchEnd); + + document.removeEventListener("touchend", handleTouchEnd); }; - - document.addEventListener('touchend', handleTouchEnd); + + document.addEventListener("touchend", handleTouchEnd); }} - onClick={(e) => tapHandler({ detail: { target: e.target, x: e.clientX } })} + onClick={(e) => + tapHandler({ detail: { target: e.target, x: e.clientX } }) + } > {step === 1 && ( @@ -297,19 +357,18 @@ const SoroPass: React.FC = () => { className="text-center" >

Welcome!

-

Split is a fun and secure way to vote using blockchain technology.

+

+ Split is a fun and secure way to vote using blockchain technology. +

- - )} - {step === 5 && ( { className="text-center" >

Register or Sign In

-

Use your device's biometrics to create or access your account.

+

+ Use your device's biometrics to create or access your account. +

)} -
- - + -
- +
); }; -export default SoroPass; \ No newline at end of file +export default SoroPass; diff --git a/dapp/src/pages/Create.tsx b/dapp/src/pages/Create.tsx index 1d4e574..964bd07 100644 --- a/dapp/src/pages/Create.tsx +++ b/dapp/src/pages/Create.tsx @@ -19,6 +19,9 @@ import { } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { motion, AnimatePresence } from "framer-motion"; +// import { uploadImageToInfura } from "@/utils"; +import axios from 'axios'; type Expense = { id: number; @@ -32,6 +35,9 @@ type Participant = { name: string; }; +const MotionCard = motion(Card); +const MotionButton = motion(Button); + export default function Create() { const [expenses, setExpenses] = useState([]); const [participants, setParticipants] = useState([]); @@ -43,9 +49,14 @@ export default function Create() { const [newParticipant, setNewParticipant] = useState(""); const [splitType, setSplitType] = useState("manual"); const [totalAmount, setTotalAmount] = useState(0); + const [ipfsCid, setIpfsCid] = useState(null); const [receiptImage, setReceiptImage] = useState(null); const fileInputRef = useRef(null); const { toast } = useToast(); + const [ocrItems, setOcrItems] = useState>([]); + const [ocrTotalAmount, setOcrTotalAmount] = useState(0); + const [ocrMerchantName, setOcrMerchantName] = useState(''); + const [ocrDate, setOcrDate] = useState(''); const addExpense = () => { if (newExpense.description && newExpense.amount > 0 && newExpense.paidBy) { @@ -131,9 +142,9 @@ export default function Create() { Object.entries(balances).forEach(([person, balance]) => { if (balance > 0) { - messages.push(`${person} is owed ${balance.toFixed(2)}`); + messages.push(`${person} is owed £${balance.toFixed(2)}`); } else if (balance < 0) { - messages.push(`${person} owes ${Math.abs(balance).toFixed(2)}`); + messages.push(`${person} owes £${Math.abs(balance).toFixed(2)}`); } }); @@ -150,20 +161,163 @@ export default function Create() { }); }; - const handleImageUpload = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; + const handleImageUpload = async (event: React.ChangeEvent) => { + if (!event?.target?.files) return null; + const file = event?.target?.files[0]; if (file) { - const reader = new FileReader(); - reader.onloadend = () => { - setReceiptImage(reader.result as string); - }; - reader.readAsDataURL(file); + try { + const reader = new FileReader(); + reader.onloadend = () => { + setReceiptImage(reader.result as string); + }; + reader.readAsDataURL(file); + + const formData = new FormData(); + formData.append('api_key', 'TEST'); // Replace 'TEST' with your actual API key + formData.append('recognizer', 'auto'); + formData.append('ref_no', `ocr_react_${Date.now()}`); + formData.append('file', file); + + const response = await axios.post('https://ocr.asprise.com/api/v1/receipt', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + const ocrResult = response.data; + console.log("OCR Result:", ocrResult); + + processOcrResult(ocrResult); + + } catch (error) { + console.error("Upload failed:", error); + } } }; + const processOcrResult = (ocrResult: any) => { + if (ocrResult && ocrResult.receipts && ocrResult.receipts.length > 0) { + const receipt = ocrResult.receipts[0]; + + const items = receipt.items?.map((item: any) => ({ + description: item.description || 'Unknown Item', + amount: parseFloat(item.amount) || 0 + })) || []; + setOcrItems(items); + + const totalAmount = parseFloat(receipt.total) || 0; + setOcrTotalAmount(totalAmount); + setTotalAmount(totalAmount); + setOcrMerchantName(receipt.merchant_name || 'Unknown Merchant'); + + setOcrDate(receipt.date || 'Unknown Date'); + + const itemsTotal = items.reduce((sum, item) => sum + item.amount, 0); + const difference = Math.abs(totalAmount - itemsTotal); + + if (difference > 0.01) { // Allow for small rounding differences + toast({ + title: "Receipt Discrepancy", + description: `There's a difference of ${difference.toFixed(2)} between items total and receipt total.`, + variant: "warning", + }); + } + + displayOcrResults(items, totalAmount, itemsTotal); + } + }; + + const displayOcrResults = (items: Array<{ description: string; amount: number }>, totalAmount: number, itemsTotal: number) => { + toast({ + title: "Receipt Processed", + description: ( +
+

Merchant: {ocrMerchantName.toString()}

+

Items: {items.length}

+

Items Total: £{itemsTotal.toFixed(2)}

+

Receipt Total: £{totalAmount.toFixed(2)}

+ +
+ ), + duration: 10000, + }); + }; + + const showDetailedResults = (items: Array<{ description: string; amount: number }>) => { + toast({ + title: "Detailed Receipt Items", + description: ( +
    + {items.map((item, index) => ( +
  • {item.description}: ${item.amount.toFixed(2)}
  • + ))} +
+ ), + duration: 15000, + }); + }; + return ( -
- + +
+
+ +
+ + New Group + + + Split expenses, not friendships! 💸🤝 + +
+
+ Add Participants @@ -174,28 +328,78 @@ export default function Create() { value={newParticipant} onChange={(e) => setNewParticipant(e.target.value)} /> - -
-
- {participants.map((participant) => ( -
- {participant.name} -
- ))} +
+ + + {participants.map((participant) => ( + + {participant.name} + + ))} + + -
+ + - + Expense Details @@ -332,9 +536,14 @@ export default function Create() { )} {splitType === "manual" && ( - + )}
@@ -348,13 +557,15 @@ export default function Create() { className="hidden" ref={fileInputRef} /> - + {receiptImage && ( Image uploaded @@ -375,36 +586,56 @@ export default function Create() { {splitType === "manual" && ( -
- {expenses.map((expense) => ( -
- - {expense.description} - ${expense.amount.toFixed(2)} (Paid - by {expense.paidBy}) - - -
- ))} -
+ + {expense.description} - ${expense.amount.toFixed(2)} (Paid + by {expense.paidBy}) + + removeExpense(expense.id)} + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + + + + ))} + +
)} - + - -
+ + ); } diff --git a/dapp/src/pages/Home.tsx b/dapp/src/pages/Home.tsx index ef57929..34aa7fd 100644 --- a/dapp/src/pages/Home.tsx +++ b/dapp/src/pages/Home.tsx @@ -1,27 +1,42 @@ -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { UserPlus, - DollarSign, - Send, - ArrowRight, PieChart, Users, CreditCard, - Activity, Plus, + Wallet, ArrowRightLeft, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +// import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { useStellarWallets } from "@/context/StellarWalletsContext"; -import { truncateStr } from "@/utils"; +import { ISupportedWallet } from "@creit.tech/stellar-wallets-kit"; +import { contractRead, truncateStr } from "@/utils"; +// import { useMediaQuery } from "react-responsive"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useAuth } from "@/hooks/use-auth"; +import { nativeToScVal, scValToNative } from "@stellar/stellar-sdk"; +const MotionCard = motion(Card); +// const MotionButton = motion(Button); const PaymentSplitterBanner = () => { + // const isMobile = useMediaQuery({ maxWidth: 768 }); const [userName, setUserName] = useState(""); const [stellarAddress, setStellarAddress] = useState(""); const stellarWalletsKit = useStellarWallets(); + const { + isAuthenticated, + connection, + userAddress, + bundlerKey, + register, + signIn, + signOut, + initializeBundler, + } = useAuth(); const menuItems = [ { icon: Plus, label: "New Split" }, @@ -60,23 +75,78 @@ const PaymentSplitterBanner = () => { }, ]; + const fetchGroupById = async (id = 0) => { + const formattedAddress = nativeToScVal("0", { type: "symbol" }); + + const name = await contractRead(bundlerKey, "get_group", [ + formattedAddress, + ]); + const decodedName = scValToNative(name.result?.retval!); + console.log("name ", name, " ", decodedName); + }; + + useEffect(() => { + initializeBundler(); + }, []); + + useEffect(() => { + (async () => { + if (!bundlerKey) return; + const formattedAddress = nativeToScVal( + "GAIKTILDUD5TXL2FY5F4RAPDTTLUVKIOW3YA3WXKQM5RBEFLSUPZIMCE", + { type: "address" } + ); + + const name = await contractRead(bundlerKey, "get_member", [ + formattedAddress, + ]); + const decodedName = scValToNative(name.result?.retval!); + console.log("name ", name, " ", decodedName); + setUserName(decodedName.nickname); + + await fetchGroupById(); + })(); + }, [isAuthenticated]); + useEffect(() => { (async () => { const { address } = await stellarWalletsKit.getAddress(); console.log(address); setStellarAddress(address); + console.log(userName); })(); }, []); - useEffect(() => { - setTimeout(() => setUserName("Alex"), 1000); - }, []); + // useEffect(() => { + // setTimeout(() => setUserName("Alex"), 1000); + // }, []); + + const handleConnect = async () => { + try { + if (stellarAddress != "") { + stellarWalletsKit.disconnect(); + setStellarAddress(""); + return; + } + console.log(stellarWalletsKit); + await stellarWalletsKit.openModal({ + onWalletSelected: async (option: ISupportedWallet) => { + stellarWalletsKit.setWallet(option.id); + const { address } = await stellarWalletsKit.getAddress(); + console.log(address); + setStellarAddress(address); + }, + }); + } catch (error) { + console.error("Failed to connect wallet:", error); + } + }; return (
{ transition={{ duration: 0.5 }} > {/* Welcome to SplitPay */} - GM Jack + GM {userName} {
+
+ + + + + User Profile + + + +
+
+ + + JD + +
+

@{userName}

+

+ {truncateStr(userAddress, 5)} +

+
+
+
+
+ + 5 Groups +
+ {/* */} + + {isAuthenticated.toString()} +
+
+
+
+
+ +
+
{
-
-
+
+

All the features in one app

diff --git a/dapp/src/pages/Landing.tsx b/dapp/src/pages/Landing.tsx new file mode 100644 index 0000000..ee7226a --- /dev/null +++ b/dapp/src/pages/Landing.tsx @@ -0,0 +1,220 @@ +import React, { useState } from "react"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { ChevronRight, Coins, Users, Zap } from "lucide-react"; +import { useAuth } from "@/hooks/use-auth"; +import { Input } from "@/components/ui/input"; +const Landing = () => { + const { + isAuthenticated, + connection, + userAddress, + bundlerKey, + register, + signIn, + signOut, + initializeBundler, + } = useAuth(); + const [isRegistering, setIsRegistering] = useState(false); + const [username, setUsername] = useState(""); + + const handleRegister = (e: React.FormEvent) => { + e.preventDefault(); + console.log("Registering with username:", username); + register(); + }; + + const handleWalletLogin = () => { + console.log("Logging in with wallet"); + signIn(); + }; + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.1 }, + }, + }; + + const itemVariants = { + hidden: { y: 20, opacity: 0 }, + visible: { + y: 0, + opacity: 1, + transition: { type: "spring", stiffness: 100 }, + }, + }; + + return ( +
+ {/*
*/} + + {/* Background Image Overlay */} +
+ + + +
+
+

+ SplitterTab +

+

+ Split expenses with crypto, revolutionize your finances +

+ + + + {isRegistering ? "Register" : "Login"} + + + Choose your authentication method + + + + + {isRegistering ? ( +
+ setUsername(e.target.value)} + className="bg-gray-800 border-gray-700 text-white placeholder-gray-500" + /> + +
+ ) : ( + + )} +
+ + + +
+
+
+
+
+ + + + + + About SplitterTab + + + +

+ SplitterTab is the ultimate solution for splitting expenses + using cryptocurrencies. Whether you're traveling with friends, + sharing a house, or managing group purchases, CryptoSplit makes + it easy to keep track of expenses and settle debts using your + favorite cryptocurrencies. +

+
+
+
+ + + + Features + + {[ + { + icon: Coins, + title: "Multi-Crypto Support", + description: "Split expenses using various cryptocurrencies", + }, + { + icon: Users, + title: "Group Management", + description: "Create and manage expense groups easily", + }, + { + icon: Zap, + title: "Passkey Support", + description: "Settle debts quickly with Passkey-powered wallets", + }, + ].map((feature, index) => ( + + + + +
+

+ {feature.title} +

+

+ {feature.description} +

+
+
+
+
+ ))} +
+ + + Built for EasyA Stellar London hackathon + +
+
+ ); +}; + +export default Landing; diff --git a/dapp/src/pages/Profile.tsx b/dapp/src/pages/Profile.tsx index 5a7573e..7944387 100644 --- a/dapp/src/pages/Profile.tsx +++ b/dapp/src/pages/Profile.tsx @@ -2,6 +2,17 @@ import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; +import { motion, AnimatePresence } from "framer-motion"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Progress } from "@/components/ui/progress"; +import { Check, X } from "lucide-react"; type Group = { id: number; @@ -11,7 +22,25 @@ type Group = { isCreator: boolean; }; +interface Wallet { + id: number; + user: string; + amount: number; + completed: boolean; +} + +interface ExpenseDetails { + id: number; + organizer: string; + totalAmount: number; + wallets: Wallet[]; +} + +const MotionCard = motion(Card); +const MotionButton = motion(Button); + export default function Profile() { + const [isOpen, setIsOpen] = useState(false); const [groups, setGroups] = useState([ { id: 1, @@ -30,6 +59,25 @@ export default function Profile() { ]); const { toast } = useToast(); + const expenseDetails: ExpenseDetails = { + id: 1, + organizer: "John Doe", + totalAmount: 1000, + wallets: [ + { id: 1, user: "Alice", amount: 250, completed: true }, + { id: 2, user: "Bob", amount: 250, completed: false }, + { id: 3, user: "Charlie", amount: 250, completed: true }, + { id: 4, user: "David", amount: 250, completed: false }, + ], + }; + + const totalPaid = expenseDetails.wallets.reduce( + (sum, wallet) => (wallet.completed ? sum + wallet.amount : sum), + 0 + ); + + const progressPercentage = (totalPaid / expenseDetails.totalAmount) * 100; + const handlePayment = (groupId: number) => { setGroups( groups.map((group) => { @@ -45,56 +93,192 @@ export default function Profile() { }); }; - const handleWithdraw = (groupId: number) => { - toast({ - title: "Withdrawal Initiated", - description: "Your withdrawal request has been submitted.", - }); - }; + const handleWithdraw = () => + // groupId: number + { + toast({ + title: "Withdrawal Initiated", + description: "Your withdrawal request has been submitted.", + }); + }; return ( -
-

Your Expense Groups

- {groups.map((group) => ( - - - {group.name} - - -
- Total Amount: ${group.totalAmount} - Paid Amount: ${group.paidAmount} -
-
-
-
- {group.isCreator ? ( - - ) : ( - - )} -
-
- ))} -
+ + + {/* +
+ Click to view expense details +
+
*/} + + + + Expense Details + + Organized by {expenseDetails.organizer} + + +
+

Wallets

+
    + {expenseDetails.wallets.map((wallet) => ( +
  • + + {wallet.user}: ${wallet.amount} + + {wallet.completed ? ( + + ) : ( + + )} +
  • + ))} +
+
+
+

Payment Progress

+ +

+ ${totalPaid} paid of ${expenseDetails.totalAmount} total +

+
+ +
+ +
+
+ +
+ + Your Expense Groups + + + Where friends split bills and HODL together! 🚀💰 + +
+
+ {/* + Your Expense Groups + */} + + {groups.map((group) => ( + + + {group.name} + + + + Total Amount: ${group.totalAmount} + Paid Amount: ${group.paidAmount} + +
+ +
+ +
+ {group.isCreator ? ( + handleWithdraw() + // group.id + } + className="mt-4 w-full text-gray-700" + disabled={group.paidAmount < group.totalAmount} + variant="outline" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + Withdraw Funds + + ) : ( + handlePayment(group.id)} + className="mt-4 w-full text-gray-700" + disabled={group.paidAmount === group.totalAmount} + variant="outline" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + Pay Your Share + + )} +
+
+
+
+ ))} +
+
+
); } diff --git a/dapp/src/pages/Settings.tsx b/dapp/src/pages/Settings.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/dapp/src/utils.ts b/dapp/src/utils.ts index ec2e0c3..2f3c61c 100644 --- a/dapp/src/utils.ts +++ b/dapp/src/utils.ts @@ -1,3 +1,89 @@ +import axios from "axios"; +import { SorobanRpc } from "@stellar/stellar-sdk"; +import { + Keypair, + xdr, + Address, + Operation, + TransactionBuilder, + Account, + scValToNative, +} from "@stellar/stellar-sdk"; +import { splitterContract } from "./constants"; + +export const contractRead = async ( + bundlerKey: Keypair, + // accountContractId: string, + functionName: string, + functionArgs: any[] +) => { + const key = bundlerKey; + + const op = Operation.invokeContractFunction({ + contract: splitterContract, + function: functionName, //"votes", + args: functionArgs, + // [ + // xdr.ScVal.scvAddress(Address.fromString(accountContractId).toScAddress()), + // ], + }); + + const transaction = new TransactionBuilder( + new Account(key.publicKey(), "0"), + { + fee: "0", + networkPassphrase: import.meta.env.VITE_PUBLIC_networkPassphrase, + } + ) + .addOperation(op) + .setTimeout(0) + .build(); + + const rpc = new SorobanRpc.Server(import.meta.env.VITE_PUBLIC_rpcUrl); + + const simResp = await rpc.simulateTransaction(transaction); + + if (!SorobanRpc.Api.isSimulationSuccess(simResp)) { + throw simResp; + } else { + return simResp; + } +}; + +export const fetchGroups = async () => {}; + +export const uploadImageToInfura = async (imageFile: any) => { + const formData = new FormData(); + formData.append("file", imageFile); + + const infuraProjectId = import.meta.env.VITE_PUBLIC_INFURA_ID; + const infuraProjectSecret = import.meta.env.VITE_PUBLIC_INFURA_SECRET; + + console.log(infuraProjectId, `\n`, infuraProjectSecret); + + try { + const response = await axios.post( + "https://ipfs.infura.io:5001/api/v0/add", + formData, + { + headers: { + "Content-Type": "multipart/form-data", + Authorization: + "Basic " + + Buffer.from(infuraProjectId + ":" + infuraProjectSecret).toString( + "base64" + ), + }, + } + ); + + console.log("Upload successful:", response.data); + return response.data.Hash; + } catch (error) { + console.error("Error uploading to Infura:", error); + throw error; + } +}; export function truncateStr(str: string, n = 6) { if (!str) return ""; return str.length > n diff --git a/dapp/src/web.ts b/dapp/src/web.ts index 52b7496..912dd42 100644 --- a/dapp/src/web.ts +++ b/dapp/src/web.ts @@ -1,48 +1,63 @@ -import { WebPlugin } from '@capacitor/core'; -import { browserSupportsWebAuthn, browserSupportsWebAuthnAutofill, startAuthentication, startRegistration } from '@simplewebauthn/browser'; -import { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON } from '@simplewebauthn/types'; - -import type { WebAuthnPlugin } from './definitions'; +import { WebPlugin } from "@capacitor/core"; +import { + browserSupportsWebAuthn, + browserSupportsWebAuthnAutofill, + startAuthentication, + startRegistration, +} from "@simplewebauthn/browser"; +import { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/types"; + +import type { WebAuthnPlugin } from "./definitions"; export class WebAuthnWeb extends WebPlugin implements WebAuthnPlugin { - - async startRegistration(publicKeyCredentialCreationOptionsJSON: PublicKeyCredentialCreationOptionsJSON): Promise { + async startRegistration( + publicKeyCredentialCreationOptionsJSON: PublicKeyCredentialCreationOptionsJSON + ): Promise { let res; try { res = await startRegistration(publicKeyCredentialCreationOptionsJSON); } catch (error) { - return Promise.reject(error) + return Promise.reject(error); } - return Promise.resolve(res) + return Promise.resolve(res); } - async startAuthentication(requestOptionsJSON: PublicKeyCredentialRequestOptionsJSON, useBrowserAutofill?: boolean): Promise { + async startAuthentication( + requestOptionsJSON: PublicKeyCredentialRequestOptionsJSON, + useBrowserAutofill?: boolean + ): Promise { let res; try { res = await startAuthentication(requestOptionsJSON, useBrowserAutofill); } catch (error) { - return Promise.reject(error) + return Promise.reject(error); } - return Promise.resolve(res) + return Promise.resolve(res); } async isWebAuthnAvailable(): Promise<{ value: boolean }> { - return this.isAvailable('webauthn'); + return this.isAvailable("webauthn"); } async isWebAuthnAutoFillAvailable(): Promise<{ value: boolean }> { - return this.isAvailable('webauthnautofill'); + return this.isAvailable("webauthnautofill"); } - private async isAvailable(type: 'webauthn' | 'webauthnautofill'): Promise<{ value: boolean }> { + private async isAvailable( + type: "webauthn" | "webauthnautofill" + ): Promise<{ value: boolean }> { let val = false; - if (type === 'webauthn') { + if (type === "webauthn") { val = await browserSupportsWebAuthn(); } - if (type === 'webauthnautofill') { + if (type === "webauthnautofill") { val = await browserSupportsWebAuthnAutofill(); } return Promise.resolve({ value: val }); } - }