diff --git a/.env.example b/.env.example index f09d8984da..426b0c3816 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,14 @@ export NX_ADD_PLUGINS=false export ESLINT_USE_FLAT_CONFIG=false export VITE_GRAPHQL_ENDPOINT='https://mainnet-gql.tangle.tools/graphql' + +# Credits claim data (off-chain proofs) +export VITE_CREDITS_TREE_URL='/data/credits-tree.json' +export VITE_CREDITS_TREE_URL_84532='' +export VITE_CREDITS_TREE_URL_8453='' + +# Credits contract address overrides (optional) +# You can set a chain-specific address, e.g. VITE_CREDITS_ADDRESS_84532 for Base Sepolia. +export VITE_CREDITS_ADDRESS='' +export VITE_CREDITS_ADDRESS_84532='' +export VITE_CREDITS_ADDRESS_8453='' diff --git a/.gitignore b/.gitignore index cea6b6c800..d90d9da492 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,17 @@ vitest.config.*.timestamp* reports/ .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md + +# Contracts - Foundry build artifacts +contracts/**/cache/ +contracts/**/out/ +contracts/**/broadcast/ + +# Contracts - Rust/SP1 build artifacts +contracts/**/target/ + +# Contracts - Generated migration data (large files) +**/migration-proofs.json +contracts/**/evm-claims.json + +docs/cloud-qa \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..2c630a3298 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# Tangle dApp monorepo (Nx + Yarn) + +## Quick commands +- Install: `yarn install` (Node `>=18.18.x`, Yarn `4.x`) +- Run dApp: `yarn nx serve tangle-dapp` (default `http://localhost:4200`) +- Lint/test/build: `yarn lint`, `yarn test`, `yarn build` + +## Env +- Start from `.env.example` (Vite vars are `VITE_*`) +- Set `VITE_GRAPHQL_ENDPOINT` to your Envio/Hasura GraphQL (local indexer or mainnet) +- Optional: `VITE_WALLETCONNECT_PROJECT_ID` for WalletConnect + +## Local protocol repo +- `../tnt-core/` (sibling repo): protocol + claims migration contracts, gas relayer, indexer, etc. +- When running locally, ensure: + - the chain you connect the UI to matches your `tnt-core` deployments + - `VITE_GRAPHQL_ENDPOINT` points at the indexer for that chain + +## Key code locations +- App: `apps/tangle-dapp/` (Vite + React Router) +- Restaking (EVM v2): + - GraphQL hooks: `libs/tangle-shared-ui/src/data/graphql/` + - Tx hooks: `libs/tangle-shared-ui/src/data/tx/` + - Write executor: `libs/tangle-shared-ui/src/hooks/useContractWrite.ts` +- Seed scripts (Substrate dev): + - `yarn script:setupServices` (create blueprints) + - `yarn script:setupRestaking` (LST/vault/operator fixtures) diff --git a/DEDUP_REPORT.md b/DEDUP_REPORT.md new file mode 100644 index 0000000000..7b09777516 --- /dev/null +++ b/DEDUP_REPORT.md @@ -0,0 +1,70 @@ +# Deduplication Report - PR #3090 Phase 2 + +## Summary +Removed **~580 lines** of duplicate code from tangle-dapp and tangle-cloud that already exist in tangle-shared-ui. + +## Completed Deduplication + +### 1. ErrorMessage.tsx (90 lines removed) +**Files Deleted:** +- `apps/tangle-cloud/src/components/ErrorMessage.tsx` ✅ +- `apps/tangle-dapp/src/components/ErrorMessage.tsx` ✅ + +**Imports Updated:** 20 files now import from `@tangle-network/tangle-shared-ui/components/ErrorMessage` + +### 2. InputWrapper.tsx (166 lines removed) +**Files Deleted:** +- `apps/tangle-dapp/src/components/InputWrapper.tsx` ✅ + +**Imports Updated:** AmountInput, TextInput, PercentageInput, AddressInput + +### 3. InputAction.tsx (41 lines removed) +**Files Deleted:** +- `apps/tangle-dapp/src/components/InputAction.tsx` ✅ + +### 4. useInputAmount.ts (192 lines removed) +**Files Deleted:** +- `apps/tangle-dapp/src/hooks/useInputAmount.ts` ✅ + +**Imports Updated:** AmountInput.tsx + +### 5. parseChainUnits.ts (64 lines removed) +**Files Deleted:** +- `apps/tangle-dapp/src/utils/parseChainUnits.ts` ✅ + +### 6. cleanNumericInputString.ts (19 lines removed) +**Files Deleted:** +- `apps/tangle-dapp/src/utils/cleanNumericInputString.ts` ✅ + +**Imports Updated:** useCustomInputValue.ts + +### 7. ErrorsContext/ directory (55 lines removed) +**Files Deleted:** +- `apps/tangle-dapp/src/context/ErrorsContext/ErrorsContext.ts` ✅ +- `apps/tangle-dapp/src/context/ErrorsContext/useErrorCountContext.ts` ✅ +- `apps/tangle-dapp/src/context/ErrorsContext/index.ts` ✅ + +## Build Status +- ✅ tangle-cloud: Builds successfully +- ⚠️ tangle-dapp: Has pre-existing build errors unrelated to this PR (graphql export issue) + +## Files Not Deduplicated (Intentionally Different) + +### AmountInput.tsx +- tangle-dapp version is almost identical but kept for now +- Only difference is import paths (uses full package path vs relative) +- Could be unified in future PR + +### OperatorsTable.tsx +- Different implementations serving different purposes +- tangle-dapp version wraps the shared-ui version +- Intentionally different + +## Total Line Reduction +| Category | Lines Removed | +|----------|---------------| +| Components | 297 | +| Hooks | 192 | +| Utils | 83 | +| Context | 55 | +| **Total** | **~580 lines** | diff --git a/MIGRATION_CLAIM_PLAN.md b/MIGRATION_CLAIM_PLAN.md new file mode 100644 index 0000000000..29c970848a --- /dev/null +++ b/MIGRATION_CLAIM_PLAN.md @@ -0,0 +1,571 @@ +# Migration Claim System - Implementation Plan + +> **Note:** The migration claim contracts, scripts, and merkle artifacts now live in `tnt-core/packages/migration-claim`. This document is retained for historical context and may be outdated. + +## Overview + +A ZK-based claim system allowing users to migrate their Substrate chain balances to EVM by proving SR25519 key ownership. Users submit a ZK proof to claim ERC20 TNT tokens at their EVM address. + +## ZK Framework Recommendation: **SP1 (Succinct)** + +### Comparison Summary + +| Feature | SP1 | RiscZero | +|---------|-----|----------| +| Base Sepolia Verifier | ✅ `0x397A5f7f3dBd538f23DE225B51f532c34448dA9B` (Groth16) | ✅ `0x0b144e07a0826182b6b59788c34b32bfa86fb711` | +| Language Support | Rust | Rust | +| Native SR25519 | ❌ (custom circuit needed) | ❌ (custom circuit needed) | +| Proving Speed | Faster (optimized for blockchain) | Good | +| Developer Tools | Excellent (prover network) | Good | +| Open Source | MIT/Apache 2.0 | Apache 2.0 | + +### Recommendation: SP1 + +Both frameworks require custom SR25519 verification code. SP1 is recommended because: +1. **Prover Network**: Succinct provides a hosted prover network, reducing infrastructure burden +2. **Performance**: Optimized specifically for blockchain verification tasks +3. **Active Development**: More frequent updates and better documentation +4. **Same Verifier Address**: Groth16 verifier uses the same address across chains (easier deployment) + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend (React) │ +│ - Connect EVM wallet │ +│ - Input Substrate address/public key │ +│ - Sign challenge message with SR25519 key │ +│ - Generate ZK proof (via prover network or locally) │ +│ - Submit claim transaction │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ MigrationClaim.sol │ +│ - Stores Merkle root of eligible balances │ +│ - Verifies ZK proof of SR25519 key ownership │ +│ - Verifies Merkle proof of balance eligibility │ +│ - Mints/transfers TNT tokens to claimant │ +│ - Tracks claimed addresses (prevent double-claim) │ +│ - 1-year expiry → Treasury recovery │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ SP1 Verifier Gateway │ +│ Address: 0x397A5f7f3dBd538f23DE225B51f532c34448dA9B │ +│ - Verifies Groth16 proofs from SP1 guest programs │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Smart Contracts + +### 1. TNT ERC20 Token (`TNT.sol`) + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract TNT is ERC20, Ownable { + constructor(address initialOwner) + ERC20("Tangle Network Token", "TNT") + Ownable(initialOwner) + {} + + function mint(address to, uint256 amount) external onlyOwner { + _mint(to, amount); + } +} +``` + +### 2. Migration Claim Contract (`MigrationClaim.sol`) + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ISP1Verifier} from "@sp1-contracts/ISP1Verifier.sol"; + +contract MigrationClaim { + // SP1 Verifier Gateway on Base Sepolia + ISP1Verifier public constant VERIFIER = ISP1Verifier(0x397A5f7f3dBd538f23DE225B51f532c34448dA9B); + + // Verification key for SR25519 proof program (set after deployment) + bytes32 public immutable SR25519_VKEY; + + // Merkle root of eligible (substratePublicKey => balance) pairs + bytes32 public immutable merkleRoot; + + // TNT token contract + IERC20 public immutable tntToken; + + // Treasury address for unclaimed funds + address public immutable treasury; + + // Claim deadline (1 year from deployment) + uint256 public immutable claimDeadline; + + // Substrate public key (32 bytes) => claimed status + mapping(bytes32 => bool) public claimed; + + // Total allocated for claims + uint256 public totalAllocated; + uint256 public totalClaimed; + + event Claimed( + bytes32 indexed substratePublicKey, + address indexed evmAddress, + uint256 amount + ); + + event UnclaimedRecovered(uint256 amount); + + constructor( + bytes32 _sr25519Vkey, + bytes32 _merkleRoot, + address _tntToken, + address _treasury, + uint256 _totalAllocated + ) { + SR25519_VKEY = _sr25519Vkey; + merkleRoot = _merkleRoot; + tntToken = IERC20(_tntToken); + treasury = _treasury; + claimDeadline = block.timestamp + 365 days; + totalAllocated = _totalAllocated; + } + + /** + * @notice Claim TNT tokens by proving SR25519 key ownership + * @param substratePublicKey The 32-byte SR25519 public key + * @param amount The claimable amount from the snapshot + * @param merkleProof Proof that (publicKey, amount) is in the Merkle tree + * @param sp1Proof The SP1 proof of SR25519 signature verification + * @param publicValues The public values from the SP1 proof + */ + function claim( + bytes32 substratePublicKey, + uint256 amount, + bytes32[] calldata merkleProof, + bytes calldata sp1Proof, + bytes calldata publicValues + ) external { + require(block.timestamp < claimDeadline, "Claim period ended"); + require(!claimed[substratePublicKey], "Already claimed"); + + // Verify Merkle proof for balance eligibility + bytes32 leaf = keccak256(abi.encodePacked(substratePublicKey, amount)); + require(_verifyMerkleProof(merkleProof, merkleRoot, leaf), "Invalid Merkle proof"); + + // Decode and validate public values from ZK proof + ( + bytes32 provenPublicKey, + address provenEvmAddress, + bytes32 provenChallenge + ) = abi.decode(publicValues, (bytes32, address, bytes32)); + + // Ensure proof is for the correct public key and recipient + require(provenPublicKey == substratePublicKey, "Public key mismatch"); + require(provenEvmAddress == msg.sender, "EVM address mismatch"); + + // Verify challenge includes commitment to this contract and chain + bytes32 expectedChallenge = keccak256(abi.encodePacked( + address(this), + block.chainid, + msg.sender + )); + require(provenChallenge == expectedChallenge, "Invalid challenge"); + + // Verify ZK proof of SR25519 signature + VERIFIER.verifyProof(SR25519_VKEY, publicValues, sp1Proof); + + // Mark as claimed and transfer tokens + claimed[substratePublicKey] = true; + totalClaimed += amount; + + require(tntToken.transfer(msg.sender, amount), "Transfer failed"); + + emit Claimed(substratePublicKey, msg.sender, amount); + } + + /** + * @notice Recover unclaimed tokens to treasury after 1 year + */ + function recoverUnclaimed() external { + require(block.timestamp >= claimDeadline, "Claim period not ended"); + + uint256 unclaimed = totalAllocated - totalClaimed; + require(unclaimed > 0, "Nothing to recover"); + + totalAllocated = totalClaimed; // Prevent re-recovery + + require(tntToken.transfer(treasury, unclaimed), "Transfer failed"); + + emit UnclaimedRecovered(unclaimed); + } + + function _verifyMerkleProof( + bytes32[] calldata proof, + bytes32 root, + bytes32 leaf + ) internal pure returns (bool) { + bytes32 computedHash = leaf; + for (uint256 i = 0; i < proof.length; i++) { + bytes32 proofElement = proof[i]; + if (computedHash <= proofElement) { + computedHash = keccak256(abi.encodePacked(computedHash, proofElement)); + } else { + computedHash = keccak256(abi.encodePacked(proofElement, computedHash)); + } + } + return computedHash == root; + } +} +``` + +--- + +## SP1 Guest Program (ZK Circuit) + +The SP1 guest program verifies SR25519 signatures using the schnorrkel library. + +### Project Structure + +``` +tnt-core/packages/migration-claim/sp1/ +├── Cargo.toml +├── program/ +│ ├── Cargo.toml +│ └── src/ +│ └── main.rs # SP1 guest program +├── script/ +│ ├── Cargo.toml +│ └── src/ +│ └── main.rs # Host program for proof generation +└── lib/ + └── src/ + └── lib.rs # Shared types +``` + +### Guest Program (`program/src/main.rs`) + +```rust +#![no_main] +sp1_zkvm::entrypoint!(main); + +use schnorrkel::{PublicKey, Signature, signing_context}; + +/// Public values that will be exposed on-chain +#[derive(Debug)] +pub struct PublicValues { + /// The SR25519 public key being proven + pub substrate_public_key: [u8; 32], + /// The EVM address claiming the tokens + pub evm_address: [u8; 20], + /// The challenge that was signed + pub challenge: [u8; 32], +} + +pub fn main() { + // Read inputs from the host + let public_key_bytes: [u8; 32] = sp1_zkvm::io::read(); + let signature_bytes: [u8; 64] = sp1_zkvm::io::read(); + let evm_address: [u8; 20] = sp1_zkvm::io::read(); + let challenge: [u8; 32] = sp1_zkvm::io::read(); + + // Parse the SR25519 public key + let public_key = PublicKey::from_bytes(&public_key_bytes) + .expect("Invalid public key"); + + // Parse the signature + let signature = Signature::from_bytes(&signature_bytes) + .expect("Invalid signature"); + + // Create signing context (Substrate uses "substrate" context) + let ctx = signing_context(b"substrate"); + + // Verify the signature over the challenge + public_key + .verify(ctx.bytes(&challenge), &signature) + .expect("Signature verification failed"); + + // Commit public values (these are exposed on-chain) + let public_values = PublicValues { + substrate_public_key: public_key_bytes, + evm_address, + challenge, + }; + + sp1_zkvm::io::commit(&public_values.substrate_public_key); + sp1_zkvm::io::commit(&public_values.evm_address); + sp1_zkvm::io::commit(&public_values.challenge); +} +``` + +### Host Program (`script/src/main.rs`) + +```rust +use sp1_sdk::{ProverClient, SP1Stdin}; + +const ELF: &[u8] = include_bytes!("../../program/elf/riscv32im-succinct-zkvm-elf"); + +fn main() { + // Initialize the prover client + let client = ProverClient::new(); + + // Prepare inputs + let mut stdin = SP1Stdin::new(); + + // These would come from the frontend + let public_key: [u8; 32] = /* substrate public key */; + let signature: [u8; 64] = /* SR25519 signature */; + let evm_address: [u8; 20] = /* claimer's EVM address */; + let challenge: [u8; 32] = /* challenge hash */; + + stdin.write(&public_key); + stdin.write(&signature); + stdin.write(&evm_address); + stdin.write(&challenge); + + // Generate the proof + let (pk, vk) = client.setup(ELF); + let proof = client.prove(&pk, stdin).groth16().run().unwrap(); + + // The proof and public values can be submitted on-chain + println!("Proof generated successfully!"); + println!("Verification Key: {:?}", vk.bytes32()); + println!("Public Values: {:?}", proof.public_values); + println!("Proof: {:?}", proof.bytes()); +} +``` + +--- + +## Merkle Tree Structure + +### Snapshot Format + +```typescript +interface SnapshotEntry { + substrateAddress: string; // SS58 address + publicKey: string; // 32 bytes hex + balance: bigint; // Balance in smallest unit +} + +// Leaf format: keccak256(abi.encodePacked(publicKey, balance)) +``` + +### Tree Generation Script + +```typescript +import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; +import { keccak256, encodePacked } from "viem"; + +interface ClaimEntry { + publicKey: `0x${string}`; + balance: bigint; +} + +function generateMerkleTree(entries: ClaimEntry[]) { + // Format: [publicKey, balance] + const values = entries.map(e => [e.publicKey, e.balance.toString()]); + + const tree = StandardMerkleTree.of(values, ["bytes32", "uint256"]); + + return { + root: tree.root, + tree, + getProof: (publicKey: `0x${string}`, balance: bigint) => { + for (const [i, v] of tree.entries()) { + if (v[0] === publicKey && v[1] === balance.toString()) { + return tree.getProof(i); + } + } + throw new Error("Entry not found"); + } + }; +} +``` + +--- + +## Frontend Implementation + +### New Page: `/claim/migration` + +``` +apps/tangle-dapp/src/pages/claim/migration/ +├── index.tsx # Main claim page +├── components/ +│ ├── SubstrateKeyInput.tsx +│ ├── ClaimStatus.tsx +│ ├── ProofGenerator.tsx +│ └── ClaimButton.tsx +└── hooks/ + ├── useClaimEligibility.ts + ├── useGenerateProof.ts + └── useSubmitClaim.ts +``` + +### Claim Flow + +1. **Connect EVM Wallet** - User connects via RainbowKit +2. **Enter Substrate Address** - User inputs their Substrate address or public key +3. **Check Eligibility** - Frontend queries Merkle tree data for balance +4. **Sign Challenge** - User signs a challenge message with their SR25519 key using polkadot.js extension +5. **Generate Proof** - Call SP1 prover (network or local) with signature +6. **Submit Claim** - Send transaction to MigrationClaim contract + +### Challenge Message Format + +```typescript +const challenge = keccak256(encodePacked( + ["address", "uint256", "address"], + [migrationClaimAddress, chainId, userEvmAddress] +)); + +// User signs this challenge with their SR25519 key +const signature = await polkadotExtension.sign(challenge); +``` + +--- + +## Implementation Steps + +### Phase 1: Smart Contracts (Week 1-2) + +1. [ ] Create TNT ERC20 token contract +2. [ ] Create MigrationClaim contract with Merkle verification +3. [ ] Integrate SP1 verifier interface +4. [ ] Write deployment scripts +5. [ ] Deploy to Base Sepolia testnet + +### Phase 2: ZK Circuit (Week 2-3) + +1. [ ] Set up SP1 project structure +2. [ ] Implement SR25519 verification in guest program +3. [ ] Test with sample signatures +4. [ ] Generate verification key +5. [ ] Deploy verifier configuration + +### Phase 3: Backend/Scripts (Week 3) + +1. [ ] Create snapshot parsing script +2. [ ] Generate Merkle tree from snapshot +3. [ ] Create proof generation service/API +4. [ ] Set up prover infrastructure (or use Succinct Network) + +### Phase 4: Frontend (Week 3-4) + +1. [ ] Create migration claim page +2. [ ] Integrate polkadot.js extension for SR25519 signing +3. [ ] Implement eligibility checking +4. [ ] Implement proof generation flow +5. [ ] Implement claim submission +6. [ ] Add claim status tracking + +### Phase 5: Testing & Deployment (Week 4) + +1. [ ] End-to-end testing on testnet +2. [ ] Security audit considerations +3. [ ] Deploy to Base mainnet +4. [ ] Monitor and support + +--- + +## File Changes Required + +### New Files + +``` +apps/tangle-dapp/src/pages/claim/migration/ +├── index.tsx +├── components/SubstrateKeyInput.tsx +├── components/ClaimStatus.tsx +├── components/ProofGenerator.tsx +├── components/ClaimButton.tsx +├── hooks/useClaimEligibility.ts +├── hooks/useGenerateProof.ts +└── hooks/useSubmitClaim.ts + +libs/tangle-shared-ui/src/data/migration/ +├── useMigrationClaim.ts +└── merkleTree.ts + +tnt-core/packages/migration-claim/ +├── src/ +│ ├── TNT.sol +│ └── MigrationClaim.sol +├── script/ +│ └── Deploy.s.sol +└── test/ + └── MigrationClaim.t.sol + +tnt-core/packages/migration-claim/sp1/ +├── program/src/main.rs +├── script/src/main.rs +└── lib/src/lib.rs +``` + +### Modified Files + +``` +apps/tangle-dapp/src/types/index.ts # Add PagePath.CLAIM_MIGRATION +apps/tangle-dapp/src/app/app.tsx # Add route +libs/dapp-config/src/contracts.ts # Add migration contract addresses +``` + +--- + +## Security Considerations + +1. **Double-claim Prevention**: Track claimed Substrate public keys, not EVM addresses +2. **Replay Protection**: Challenge includes contract address and chain ID +3. **Front-running Protection**: Only msg.sender can claim their own proof +4. **Merkle Tree Integrity**: Root is immutable after deployment +5. **Time-lock**: 1-year claim period with treasury recovery +6. **ZK Security**: SP1's Groth16 proofs provide 128-bit security + +--- + +## Dependencies + +### Smart Contracts +- OpenZeppelin Contracts v5 +- SP1 Contracts (`@sp1-contracts`) + +### ZK Circuit +- sp1-zkvm +- schnorrkel (Rust) + +### Frontend +- @polkadot/extension-dapp (for SR25519 signing) +- @openzeppelin/merkle-tree +- viem/wagmi + +--- + +## Estimated Gas Costs + +| Operation | Estimated Gas | +|-----------|---------------| +| Deploy TNT | ~800,000 | +| Deploy MigrationClaim | ~1,200,000 | +| Claim (with proof verification) | ~350,000 - 500,000 | +| Recover Unclaimed | ~50,000 | + +--- + +## Open Questions + +1. **Prover Infrastructure**: Use Succinct Network (hosted) or self-hosted prover? +2. **Snapshot Source**: How will the Substrate chain snapshot be generated and verified? +3. **Token Supply**: Pre-mint all claimable tokens or mint on claim? +4. **Vesting**: Should claimed tokens have any vesting schedule? diff --git a/README.md b/README.md index 0b5c185b51..34f60e9986 100644 --- a/README.md +++ b/README.md @@ -56,16 +56,14 @@ This repository makes use of yarn, nodejs, and requires version node v18.18.x. T

Libraries

- `abstract-api-provider`: a collection of base and abstract classes that unify the API across multiple providers. -- `api-provider-environment`: contains the React context definitions, the app event, and functions for handling interactive feedback errors for the bridge app. +- `api-provider-environment`: contains the React context definitions, the app event, and functions for handling interactive feedback errors. - `browser-utils`: contains all the browser utility functions, such as fetch with caching, download file and string, the customized logger class, get browser platform, and the storage factory function for interacting with local storage. -- `dapp-config`: contains all configurations (chains, wallets, etc.) for the bridge dApp. +- `dapp-config`: contains all configurations (chains, wallets, contracts, etc.) for the dApps. - `dapp-types`: contains all the sharable TypeScript types and interfaces across the apps. - `icons`: contains all the sharable icons across the apps. -- `polkadot-api-provider`: the Substrate (or Polkadot) provider for the bridge. -- `tailwind-preset`: the TailwindCSS preset for all the apps. -- `tangle-shared-ui`: the library contains the logic and UI components that specialize in the Tangle Network. +- `tangle-shared-ui`: the library contains the logic and UI components that specialize in the Tangle Network (hooks, GraphQL queries, etc.). - [ui-components](./libs/ui-components/README.md): a collection of reusable components for building interfaces quickly. -- `web3-api-provider`: the EVM provider for the bridge. +- `web3-api-provider`: the EVM provider for wallet connections and contract interactions.
↑ Back to top ↑
diff --git a/apps/leaderboard/.env.local.example b/apps/leaderboard/.env.local.example new file mode 100644 index 0000000000..7ba363550f --- /dev/null +++ b/apps/leaderboard/.env.local.example @@ -0,0 +1,20 @@ +# Leaderboard Environment Configuration +# Copy this to .env.local and adjust as needed + +# ========================================== +# Envio GraphQL Endpoints +# ========================================== +# For local development with the simulation environment: +VITE_ENVIO_MAINNET_ENDPOINT=http://localhost:8080/v1/graphql +VITE_ENVIO_TESTNET_ENDPOINT=http://localhost:8080/v1/graphql + +# For production, set these to the deployed Envio endpoints: +# VITE_ENVIO_MAINNET_ENDPOINT=https://indexer.tangle.tools/v1/graphql +# VITE_ENVIO_TESTNET_ENDPOINT=https://testnet-indexer.tangle.tools/v1/graphql + +# ========================================== +# Local Simulation Environment (optional) +# ========================================== +# These are used by the activity generator script +RPC_URL=http://localhost:8545 +ACTIVITY_INTERVAL_MS=10000 diff --git a/apps/leaderboard/package-lock.json b/apps/leaderboard/package-lock.json new file mode 100644 index 0000000000..d99235b759 --- /dev/null +++ b/apps/leaderboard/package-lock.json @@ -0,0 +1,221 @@ +{ + "name": "@tangle-network/leaderboard", + "version": "0.0.5", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@tangle-network/leaderboard", + "version": "0.0.5", + "license": "Apache-2.0", + "dependencies": { + "viem": "^2.41.2" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/abitype": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.1.0.tgz", + "integrity": "sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ox": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.9.6.tgz", + "integrity": "sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.0.9", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem": { + "version": "2.41.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.41.2.tgz", + "integrity": "sha512-LYliajglBe1FU6+EH9mSWozp+gRA/QcHfxeD9Odf83AdH5fwUS7DroH4gHvlv6Sshqi1uXrYFA2B/EOczxd15g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.1.0", + "isows": "1.0.7", + "ox": "0.9.6", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/apps/leaderboard/package.json b/apps/leaderboard/package.json index ea7863ea81..0a012d8666 100644 --- a/apps/leaderboard/package.json +++ b/apps/leaderboard/package.json @@ -2,5 +2,12 @@ "name": "@tangle-network/leaderboard", "version": "0.0.5", "license": "Apache-2.0", - "type": "module" + "type": "module", + "scripts": { + "local:start": "./scripts/start-local-env.sh", + "local:activity": "node scripts/activity-generator.mjs" + }, + "dependencies": { + "viem": "^2.41.2" + } } diff --git a/apps/leaderboard/scripts/README.md b/apps/leaderboard/scripts/README.md new file mode 100644 index 0000000000..3572a26b4d --- /dev/null +++ b/apps/leaderboard/scripts/README.md @@ -0,0 +1,13 @@ +# Local Environment Scripts + +The local environment scripts have been moved to the root `scripts/local-env/` directory to be shared across all dApps. + +## Usage + +From the dapp root directory: + +```bash +./scripts/local-env/start-local-env.sh +``` + +See [scripts/local-env/README.md](../../../scripts/local-env/README.md) for full documentation. diff --git a/apps/leaderboard/src/constants/query.ts b/apps/leaderboard/src/constants/query.ts index 6e8cfaa848..071f4fc7c0 100644 --- a/apps/leaderboard/src/constants/query.ts +++ b/apps/leaderboard/src/constants/query.ts @@ -1,4 +1,3 @@ export const INDEXING_PROGRESS_QUERY_KEY = 'indexingProgress'; export const LEADERBOARD_QUERY_KEY = 'leaderboard'; -export const LATEST_FINALIZED_BLOCK_QUERY_KEY = 'latestFinalizedBlock'; -export const ACCOUNT_IDENTITIES_QUERY_KEY = 'accountIdentities'; +export const LATEST_TIMESTAMP_QUERY_KEY = 'latestTimestamp'; diff --git a/apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts b/apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts index dca0f4e1b8..aa358b037e 100644 --- a/apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts +++ b/apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts @@ -1,26 +1,89 @@ import { BLOCK_TIME_MS } from '@tangle-network/dapp-config/constants/tangle'; -import { graphql } from '@tangle-network/tangle-shared-ui/graphql'; import { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql'; -import { executeGraphQL } from '@tangle-network/tangle-shared-ui/utils/executeGraphQL'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery as _useQuery } from '@tanstack/react-query'; import { INDEXING_PROGRESS_QUERY_KEY } from '../../../constants/query'; -const IndexingProgressQueryDocument = graphql(/* GraphQL */ ` +interface IndexingMetadata { + lastProcessedHeight: number; + targetHeight: number; +} + +// Envio chain_metadata query - uses the Envio-specific table +const INDEXING_PROGRESS_QUERY = ` query IndexingProgress { - _metadata { - lastProcessedHeight - targetHeight + chain_metadata { + first_event_block_number + latest_processed_block + num_events_processed + chain_id } } -`); +`; + +const getEndpoint = (network: NetworkType): string => { + if (network === 'MAINNET') { + return ( + import.meta.env.VITE_ENVIO_MAINNET_ENDPOINT || + 'http://localhost:8080/v1/graphql' + ); + } + return ( + import.meta.env.VITE_ENVIO_TESTNET_ENDPOINT || + 'http://localhost:8080/v1/graphql' + ); +}; + +interface ChainMetadataRow { + first_event_block_number: number; + latest_processed_block: number; + num_events_processed: number; + chain_id: number; +} + +const fetcher = async ( + network: NetworkType, +): Promise => { + const endpoint = getEndpoint(network); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + query: INDEXING_PROGRESS_QUERY, + }), + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const result = (await response.json()) as { + data: { chain_metadata: ChainMetadataRow[] }; + errors?: Array<{ message: string }>; + }; + + if (result.errors?.length) { + console.warn('GraphQL errors:', result.errors); + return null; + } + + const metadata = result.data.chain_metadata?.[0]; + if (!metadata) { + return null; + } -const fetcher = async (network: NetworkType) => { - const result = await executeGraphQL(network, IndexingProgressQueryDocument); - return result.data._metadata; + // Envio tracks latest_processed_block, we estimate target as a bit ahead + return { + lastProcessedHeight: metadata.latest_processed_block, + targetHeight: metadata.latest_processed_block + 1, // Estimate target + }; }; export function useIndexingProgress(network: NetworkType) { - return useQuery({ + return _useQuery({ queryKey: [INDEXING_PROGRESS_QUERY_KEY, network], queryFn: () => fetcher(network), refetchInterval: BLOCK_TIME_MS, diff --git a/apps/leaderboard/src/features/leaderboard/components/ExpandedInfo.tsx b/apps/leaderboard/src/features/leaderboard/components/ExpandedInfo.tsx index dafc434d3a..cc2d9a969f 100644 --- a/apps/leaderboard/src/features/leaderboard/components/ExpandedInfo.tsx +++ b/apps/leaderboard/src/features/leaderboard/components/ExpandedInfo.tsx @@ -1,178 +1,663 @@ -import { CircleIcon } from '@radix-ui/react-icons'; -import { CheckboxCircleFill } from '@tangle-network/icons'; +import { Spinner } from '@tangle-network/icons'; import { - Card, - isSubstrateAddress, + InfoIconWithTooltip, KeyValueWithButton, Typography, - ValidatorIdentity, } from '@tangle-network/ui-components'; import { Row } from '@tanstack/react-table'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { twMerge } from 'tailwind-merge'; import { Account } from '../types'; +import { BadgeEnum, BADGE_ICON_RECORD } from '../constants'; +import { useAccountActivity } from '../queries'; import { createAccountExplorerUrl } from '../utils/createAccountExplorerUrl'; -import { formatDisplayBlockNumber } from '../utils/formatDisplayBlockNumber'; interface ExpandedInfoProps { row: Row; } -export const ExpandedInfo: React.FC = ({ row }) => { - const account = row.original; - const address = account.id; - const accountNetwork = account.network; +const ZERO_BIG_INT = BigInt(0); - // Helper function to render a detail row with label and value - const DetailRow = ({ - label, - value, - }: { - label: string; - value: React.ReactNode; - }) => ( -
- {label}: - {value} +const ACTIVITY_POINT_INFO: Record< + BadgeEnum, + { label: string; description: string; activityKey: keyof Account['activity'] } +> = { + [BadgeEnum.RESTAKE_DEPOSITOR]: { + label: 'Deposits', + description: + 'Points earned from depositing assets into the restaking protocol', + activityKey: 'depositCount', + }, + [BadgeEnum.RESTAKE_DELEGATOR]: { + label: 'Delegations', + description: 'Points earned from delegating to operators', + activityKey: 'delegationCount', + }, + [BadgeEnum.LIQUID_STAKER]: { + label: 'Liquid Staking', + description: 'Points earned from liquid vault positions', + activityKey: 'liquidVaultPositionCount', + }, + [BadgeEnum.OPERATOR]: { + label: 'Operator', + description: 'Points earned from running as a network operator', + activityKey: 'depositCount', // Operators are tracked separately + }, + [BadgeEnum.BLUEPRINT_OWNER]: { + label: 'Blueprints', + description: 'Points earned from creating and owning blueprints', + activityKey: 'blueprintCount', + }, + [BadgeEnum.SERVICE_PROVIDER]: { + label: 'Services', + description: 'Points earned from providing services on the network', + activityKey: 'serviceCount', + }, + [BadgeEnum.JOB_CALLER]: { + label: 'Job Calls', + description: 'Points earned from submitting jobs to services', + activityKey: 'jobCallCount', + }, +}; + +const formatPoints = (points: bigint): string => { + return points.toLocaleString(); +}; + +const ProgressBar = ({ + value, + max, + color, +}: { + value: bigint; + max: bigint; + color: 'blue' | 'purple'; +}) => { + const percentage = + max > ZERO_BIG_INT ? Number((value * BigInt(100)) / max) : 0; + const colorClass = + color === 'blue' + ? 'bg-blue-500 dark:bg-blue-400' + : 'bg-purple-500 dark:bg-purple-400'; + + return ( +
+
0 ? 2 : 0)}%` }} + />
); +}; + +const StatCard = ({ + label, + value, + subValue, + color, + percentage, + tooltip, +}: { + label: string; + value: string; + subValue?: string; + color?: 'blue' | 'purple' | 'green'; + percentage?: number; + tooltip?: string; +}) => { + const colorClasses = { + blue: 'text-blue-600 dark:text-blue-400', + purple: 'text-purple-600 dark:text-purple-400', + green: 'text-green-600 dark:text-green-400', + }; - // Helper function to render task completion indicator - const TaskIndicator = ({ - completed, - label, - }: { - completed?: boolean; - label: string; - }) => ( -
- {completed ? ( - - ) : ( - + return ( +
+
+ + {label} + + {tooltip && } +
+
+ + {value} + + {subValue && ( + + {subValue} + + )} + {percentage !== undefined && ( + + ({percentage}%) + + )} +
+
+ ); +}; + +const ActivityBadge = ({ + badge, + count, + isActive, +}: { + badge: BadgeEnum; + count: number; + isActive: boolean; +}) => { + const info = ACTIVITY_POINT_INFO[badge]; + + return ( +
+ {BADGE_ICON_RECORD[badge]} +
+
+ + {info.label} + + +
+ + {count} {count === 1 ? 'activity' : 'activities'} + +
+ {isActive && ( +
+ + Active + +
)} - {label}
); +}; - // Helper function to create a section with title and content - const Section = ({ - title, - children, - }: { - title: string; - children: React.ReactNode; - }) => ( -
- - {title} - -
{children}
+const CompactActivityBadge = ({ + badge, + isActive, +}: { + badge: BadgeEnum; + isActive: boolean; +}) => { + return ( +
+ {BADGE_ICON_RECORD[badge]}
); +}; + +const safeBigInt = (value: string | undefined | null): bigint => { + if (!value) return ZERO_BIG_INT; + try { + return BigInt(value); + } catch { + return ZERO_BIG_INT; + } +}; + +export const ExpandedInfo: React.FC = ({ row }) => { + const account = row.original; + const address = account.id; + const accountNetwork = account.network; + + // Fetch activity data for this account + const { data: activityData, isPending: isLoadingActivity } = + useAccountActivity(accountNetwork, address); + + const { mainnetPercentage, testnetPercentage, totalPoints } = useMemo(() => { + const total = account.totalPoints; + const mainnet = account.pointsBreakdown.mainnet; + const testnet = account.pointsBreakdown.testnet; + + if (total === ZERO_BIG_INT) { + return { mainnetPercentage: 0, testnetPercentage: 0, totalPoints: total }; + } + + return { + mainnetPercentage: Math.round(Number((mainnet * BigInt(100)) / total)), + testnetPercentage: Math.round(Number((testnet * BigInt(100)) / total)), + totalPoints: total, + }; + }, [account.totalPoints, account.pointsBreakdown]); + + // Calculate activity counts from fetched data + const activityCounts = useMemo(() => { + if (!activityData) { + return { + depositCount: 0, + delegationCount: 0, + liquidVaultPositionCount: 0, + blueprintCount: 0, + serviceCount: 0, + jobCallCount: 0, + isOperator: false, + }; + } + + const delegator = activityData.Delegator_by_pk; + + return { + depositCount: + delegator?.assetPositions?.filter( + (pos) => safeBigInt(pos.totalDeposited) > ZERO_BIG_INT, + ).length ?? 0, + delegationCount: + delegator?.delegations?.filter( + (del) => safeBigInt(del.shares) > ZERO_BIG_INT, + ).length ?? 0, + liquidVaultPositionCount: + delegator?.liquidVaultPositions?.filter( + (pos) => safeBigInt(pos.shares) > ZERO_BIG_INT, + ).length ?? 0, + blueprintCount: activityData.Blueprint?.length ?? 0, + serviceCount: activityData.Service?.length ?? 0, + jobCallCount: activityData.JobCall?.length ?? 0, + isOperator: (activityData.Operator?.length ?? 0) > 0, + }; + }, [activityData]); + + // Calculate badges based on activity + const earnedBadges = useMemo(() => { + const badges: BadgeEnum[] = []; + + if (activityCounts.depositCount > 0) { + badges.push(BadgeEnum.RESTAKE_DEPOSITOR); + } + if (activityCounts.delegationCount > 0) { + badges.push(BadgeEnum.RESTAKE_DELEGATOR); + } + if (activityCounts.liquidVaultPositionCount > 0) { + badges.push(BadgeEnum.LIQUID_STAKER); + } + if (activityCounts.isOperator) { + badges.push(BadgeEnum.OPERATOR); + } + if (activityCounts.blueprintCount > 0) { + badges.push(BadgeEnum.BLUEPRINT_OWNER); + } + if (activityCounts.serviceCount > 0) { + badges.push(BadgeEnum.SERVICE_PROVIDER); + } + if (activityCounts.jobCallCount > 0) { + badges.push(BadgeEnum.JOB_CALLER); + } + + return badges; + }, [activityCounts]); + + const activityBadges = useMemo(() => { + const badges = Object.values(BadgeEnum); + return badges.map((badge) => { + const info = ACTIVITY_POINT_INFO[badge]; + let count = 0; + + switch (badge) { + case BadgeEnum.RESTAKE_DEPOSITOR: + count = activityCounts.depositCount; + break; + case BadgeEnum.RESTAKE_DELEGATOR: + count = activityCounts.delegationCount; + break; + case BadgeEnum.LIQUID_STAKER: + count = activityCounts.liquidVaultPositionCount; + break; + case BadgeEnum.OPERATOR: + count = activityCounts.isOperator ? 1 : 0; + break; + case BadgeEnum.BLUEPRINT_OWNER: + count = activityCounts.blueprintCount; + break; + case BadgeEnum.SERVICE_PROVIDER: + count = activityCounts.serviceCount; + break; + case BadgeEnum.JOB_CALLER: + count = activityCounts.jobCallCount; + break; + } - const { testnetTaskCompletion } = account; + const isActive = earnedBadges.includes(badge); + return { badge, count, isActive, info }; + }); + }, [activityCounts, earnedBadges]); + + const totalActivityCount = useMemo(() => { + return ( + activityCounts.depositCount + + activityCounts.delegationCount + + activityCounts.liquidVaultPositionCount + + activityCounts.blueprintCount + + activityCounts.serviceCount + + activityCounts.jobCallCount + ); + }, [activityCounts]); return ( -
- -
- +
+ {/* Mobile Layout - Compact Single Column */} +
+
+ {/* Header with Address and Explorer Link */} +
+
+ +
+ + Explorer + +
+ + {/* Points Summary Row */} +
+
+ + {formatPoints(totalPoints)} + + + Total + +
+
+ + {formatPoints(account.pointsBreakdown.mainnet)} + + + Mainnet + +
+
+ + {formatPoints(account.pointsBreakdown.testnet)} + + + Testnet + +
+
+ + {/* 7-Day Change */} +
+ + Last 7 days + + + +{formatPoints(account.pointsBreakdown.lastSevenDays)} + +
+ + {/* Activity Badges - Emoji Only */} +
+
+ + Activities + + {isLoadingActivity ? ( + ) : ( - - ) - } - /> - - -
- -
- - - -
-
- - -
- - - - -
- -
- - Testnet Task Completion - + + {earnedBadges.length} badge + {earnedBadges.length !== 1 ? 's' : ''} + + )} +
+
+ {activityBadges.map(({ badge, isActive }) => ( + + ))} +
+
+
+
-
-
- - - - - + {/* Account Overview Card */} +
+
+
+ + Account + + + View on Explorer + +
+ + + +
+ - + + {account.updatedAtTimestamp && ( +
+ + Last updated:{' '} + {account.updatedAtTimestamp.toLocaleDateString()} + +
+ )} +
+
+ + {/* Points Breakdown Card */} +
+
+
+ + Points by Network + + +
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+
- + + {/* Activity & Badges Card */} +
+
+
+
+ + Activities + + +
+ {isLoadingActivity ? ( + + ) : ( + + {earnedBadges.length} badge + {earnedBadges.length !== 1 ? 's' : ''} earned + + )} +
+ +
+ {activityBadges.map(({ badge, count, isActive }) => ( + + ))} +
+ +
+ + {totalActivityCount} total activities across all categories + +
+
+
+
); }; diff --git a/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx b/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx index f6830a8c75..f1bd2b41e0 100644 --- a/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx +++ b/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx @@ -5,8 +5,6 @@ import TableStatus from '@tangle-network/tangle-shared-ui/components/tables/Tabl import type { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql'; import { Input, - isSubstrateAddress, - isValidAddress, KeyValueWithButton, Table, TabsListWithAnimation, @@ -16,8 +14,8 @@ import { TooltipBody, TooltipTrigger, Typography, - ValidatorIdentity, } from '@tangle-network/ui-components'; +import { isEvmAddress } from '@tangle-network/ui-components/utils/isEvmAddress20'; import { Card } from '@tangle-network/ui-components/components/Card'; import { createColumnHelper, @@ -28,16 +26,18 @@ import { useReactTable, } from '@tanstack/react-table'; import cx from 'classnames'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { twMerge } from 'tailwind-merge'; -import { useLatestFinalizedBlock } from '../../../queries'; +import { useLatestTimestamp } from '../../../queries'; import { SyncProgressIndicator } from '../../indexingProgress'; -import { BLOCK_COUNT_IN_SEVEN_DAYS } from '../constants'; -import { useLeaderboard } from '../queries'; -import { useAccountIdentities } from '../queries/accountIdentitiesQuery'; +import { RoleFilterEnum, SEVEN_DAYS_IN_SECONDS } from '../constants'; +import { + getAccountIdsForRoles, + useLeaderboard, + useRoleAccounts, +} from '../queries'; import { Account } from '../types'; import { createAccountExplorerUrl } from '../utils/createAccountExplorerUrl'; -import { formatDisplayBlockNumber } from '../utils/formatDisplayBlockNumber'; import { processLeaderboardRecord } from '../utils/processLeaderboardRecord'; import { BadgesCell } from './BadgesCell'; import { ExpandedInfo } from './ExpandedInfo'; @@ -45,6 +45,7 @@ import { HeaderCell } from './HeaderCell'; import { MiniSparkline } from './MiniSparkline'; import { Overlay } from './Overlay'; import { FirstPlaceIcon, SecondPlaceIcon, ThirdPlaceIcon } from './RankIcon'; +import { RoleFilter } from './RoleFilter'; import { TrendIndicator } from './TrendIndicator'; const COLUMN_ID = { @@ -52,7 +53,6 @@ const COLUMN_ID = { ACCOUNT: 'account', BADGES: 'badges', TOTAL_POINTS: 'totalPoints', - ACTIVITY: 'activity', POINTS_HISTORY: 'pointsHistory', } as const; @@ -90,46 +90,26 @@ const COLUMNS = [ cell: (props) => { const address = props.getValue(); const accountNetwork = props.row.original.network; - const identity = props.row.original.identity; - - if (isSubstrateAddress(address)) { - return ( - - Created{' '} - {formatDisplayBlockNumber( - props.row.original.createdAt, - props.row.original.createdAtTimestamp, - )} - - } - /> - ); - } + const updatedAt = props.row.original.updatedAtTimestamp; return ( - ); }, @@ -160,27 +140,6 @@ const COLUMNS = [ ), }), - COLUMN_HELPER.accessor('activity', { - id: COLUMN_ID.ACTIVITY, - header: () => , - cell: ({ row }) => ( -
- - {row.original.activity.depositCount} deposits - - - - {row.original.activity.delegationCount} delegations - -
- ), - }), COLUMN_HELPER.display({ id: COLUMN_ID.POINTS_HISTORY, header: () => , @@ -203,6 +162,7 @@ const getExpandedRowContent = (row: Row) => { export const LeaderboardTable = () => { const [searchQuery, setSearchQuery] = useState(''); const [expanded, setExpanded] = useState({}); + const [selectedRoles, setSelectedRoles] = useState([]); const [pagination, setPagination] = useState({ pageIndex: 0, @@ -213,22 +173,61 @@ export const LeaderboardTable = () => { 'MAINNET' as NetworkType, ); + const handleRoleToggle = useCallback((role: RoleFilterEnum) => { + setSelectedRoles((prev) => + prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role], + ); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + + const handleClearRoles = useCallback(() => { + setSelectedRoles([]); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + const { - data: latestBlock, - isPending: isLatestBlockPending, - error: latestBlockError, - } = useLatestFinalizedBlock(networkTab); - - // TODO: Figure out how to handle this for both mainnet and testnet - const blockNumberSevenDaysAgo = useMemo(() => { - if (isLatestBlockPending || latestBlockError) { + data: latestTimestamp, + isPending: isTimestampPending, + error: timestampError, + } = useLatestTimestamp(networkTab); + + const { data: roleAccounts, isPending: isRoleAccountsPending } = + useRoleAccounts(networkTab); + + const roleFilteredAccountIds = useMemo(() => { + if (selectedRoles.length === 0 || !roleAccounts) { + return null; + } + return getAccountIdsForRoles(roleAccounts, selectedRoles); + }, [selectedRoles, roleAccounts]); + + const roleCounts = useMemo(() => { + if (!roleAccounts) return undefined; + return { + operators: roleAccounts.operators.size, + restakers: roleAccounts.restakers.size, + developers: roleAccounts.developers.size, + customers: roleAccounts.customers.size, + }; + }, [roleAccounts]); + + // Calculate timestamp for 7 days ago (Envio uses timestamps instead of block numbers) + const timestampSevenDaysAgo = useMemo(() => { + if (isTimestampPending || timestampError) { return -1; } - const result = latestBlock.testnetBlock - BLOCK_COUNT_IN_SEVEN_DAYS; + const currentTimestamp = + networkTab === 'MAINNET' + ? latestTimestamp?.mainnetTimestamp + : latestTimestamp?.testnetTimestamp; + + if (!currentTimestamp) { + return -1; + } - return result < 0 ? 1 : result; - }, [isLatestBlockPending, latestBlockError, latestBlock?.testnetBlock]); + return currentTimestamp - SEVEN_DAYS_IN_SECONDS; + }, [isTimestampPending, timestampError, latestTimestamp, networkTab]); const accountQuery = useMemo(() => { if (!searchQuery) { @@ -237,12 +236,12 @@ export const LeaderboardTable = () => { const trimmedQuery = searchQuery.trim(); - // Use server-side filtering only for valid addresses - if (isValidAddress(trimmedQuery)) { + // Use server-side filtering only for valid EVM addresses + if (isEvmAddress(trimmedQuery)) { return trimmedQuery; } - // Use client-side filtering for identity names and other searches + // Use client-side filtering for partial address matches return undefined; }, [searchQuery]); @@ -267,23 +266,10 @@ export const LeaderboardTable = () => { shouldUseClientSideFiltering ? 0 : pagination.pageIndex * pagination.pageSize, - blockNumberSevenDaysAgo, + timestampSevenDaysAgo, accountQuery, ); - const { data: accountIdentities } = useAccountIdentities( - useMemo( - () => - leaderboardData?.nodes - .filter((node) => node !== undefined && node !== null) - .map((node) => ({ - id: node.id, - network: networkTab, - })) ?? [], - [leaderboardData?.nodes, networkTab], - ), - ); - const data = useMemo(() => { if (!leaderboardData?.nodes) { return [] as Account[]; @@ -296,41 +282,39 @@ export const LeaderboardTable = () => { index, pagination.pageIndex, pagination.pageSize, - record ? accountIdentities?.get(record.id) : null, + undefined, // activity data - loaded separately if needed + networkTab, ), ) .filter((record) => record !== null); - // Apply client-side filtering for identity names and other searches - if (!searchQuery || accountQuery || !shouldUseClientSideFiltering) { - // If no search query, server-side filtering, or query too short, return as-is - return processedData; - } - - const trimmedQuery = searchQuery.trim().toLowerCase(); + let filteredData = processedData; - // Client-side filter by identity name, address, or partial matches - return processedData.filter((account) => { - // Search by identity name - if (account.identity?.name?.toLowerCase().includes(trimmedQuery)) { - return true; - } + // Apply role-based filtering + if (roleFilteredAccountIds && roleFilteredAccountIds.size > 0) { + filteredData = filteredData.filter((account) => + roleFilteredAccountIds.has(account.id.toLowerCase()), + ); + } - // Search by address (case insensitive) - if (account.id.toLowerCase().includes(trimmedQuery)) { - return true; - } + // Apply client-side filtering for address searches + if (searchQuery && !accountQuery && shouldUseClientSideFiltering) { + const trimmedQuery = searchQuery.trim().toLowerCase(); + filteredData = filteredData.filter((account) => + account.id.toLowerCase().includes(trimmedQuery), + ); + } - return false; - }); + return filteredData; }, [ leaderboardData?.nodes, pagination.pageIndex, pagination.pageSize, - accountIdentities, searchQuery, accountQuery, shouldUseClientSideFiltering, + networkTab, + roleFilteredAccountIds, ]); const table = useReactTable({ @@ -351,41 +335,52 @@ export const LeaderboardTable = () => { return ( -
- setNetworkTab(tab as NetworkType)} - > - - - Mainnet - - - Testnet - - - +
+ {/* Top row: Tabs and Sync indicator */} +
+ setNetworkTab(tab as NetworkType)} + > + + + Mainnet + + + Testnet + + + - + +
+ + {/* Role filter */} + -
+ {/* Bottom row: Search */} +
{ ) : undefined } id="search" - placeholder="Search by address or identity name" + placeholder="Search by address" size="md" inputClassName="py-1" /> - - {/* */}
diff --git a/apps/leaderboard/src/features/leaderboard/components/MiniSparkline.tsx b/apps/leaderboard/src/features/leaderboard/components/MiniSparkline.tsx index e6d4cf7a48..68081b4df2 100644 --- a/apps/leaderboard/src/features/leaderboard/components/MiniSparkline.tsx +++ b/apps/leaderboard/src/features/leaderboard/components/MiniSparkline.tsx @@ -1,5 +1,5 @@ import { ZERO_BIG_INT } from '@tangle-network/dapp-config'; -import { BLOCK_COUNT_IN_ONE_DAY } from '../constants'; +import { SECONDS_IN_ONE_DAY } from '../constants'; import { Account } from '../types'; export const MiniSparkline = ({ @@ -22,19 +22,18 @@ export const MiniSparkline = ({ ); } - // Get the most recent block number from the history - const mostRecentBlockNumber = - pointsHistory[pointsHistory.length - 1].blockNumber; + // Get the most recent timestamp from the history + const mostRecentTimestamp = pointsHistory[pointsHistory.length - 1].timestamp; // Cumulate points for each day const cumulatedPoints = pointsHistory .reduce( (acc, snapshot) => { - // Calculate which day this block belongs to (0-6, where 0 is today) - const blocksAgo = mostRecentBlockNumber - snapshot.blockNumber; - const day = Math.floor(blocksAgo / BLOCK_COUNT_IN_ONE_DAY); + // Calculate which day this snapshot belongs to (0-6, where 0 is today) + const secondsAgo = mostRecentTimestamp - snapshot.timestamp; + const day = Math.floor(secondsAgo / SECONDS_IN_ONE_DAY); - // Only process blocks within the last 7 days + // Only process snapshots within the last 7 days if (day >= 0 && day < 7) { acc[day] = acc[day] + snapshot.points; } diff --git a/apps/leaderboard/src/features/leaderboard/components/RoleFilter.tsx b/apps/leaderboard/src/features/leaderboard/components/RoleFilter.tsx new file mode 100644 index 0000000000..ac2cd42a4c --- /dev/null +++ b/apps/leaderboard/src/features/leaderboard/components/RoleFilter.tsx @@ -0,0 +1,101 @@ +import { Button, Typography } from '@tangle-network/ui-components'; +import cx from 'classnames'; +import { FC } from 'react'; +import { + RoleFilterEnum, + ROLE_FILTER_ICONS, + ROLE_FILTER_LABELS, +} from '../constants'; + +interface RoleFilterProps { + selectedRoles: RoleFilterEnum[]; + onRoleToggle: (role: RoleFilterEnum) => void; + onClearAll: () => void; + isLoading?: boolean; + roleCounts?: { + operators: number; + restakers: number; + developers: number; + customers: number; + }; +} + +const ROLES = Object.values(RoleFilterEnum); + +export const RoleFilter: FC = ({ + selectedRoles, + onRoleToggle, + onClearAll, + isLoading, + roleCounts, +}) => { + const getRoleCount = (role: RoleFilterEnum): number | undefined => { + if (!roleCounts) return undefined; + + switch (role) { + case RoleFilterEnum.OPERATOR: + return roleCounts.operators; + case RoleFilterEnum.RESTAKER: + return roleCounts.restakers; + case RoleFilterEnum.DEVELOPER: + return roleCounts.developers; + case RoleFilterEnum.CUSTOMER: + return roleCounts.customers; + } + }; + + return ( +
+ + Filter by role: + + + {ROLES.map((role) => { + const isSelected = selectedRoles.includes(role); + const count = getRoleCount(role); + + return ( + + ); + })} + + {selectedRoles.length > 0 && ( + + )} +
+ ); +}; diff --git a/apps/leaderboard/src/features/leaderboard/constants/index.ts b/apps/leaderboard/src/features/leaderboard/constants/index.ts index f7b6042e0d..b802166ca7 100644 --- a/apps/leaderboard/src/features/leaderboard/constants/index.ts +++ b/apps/leaderboard/src/features/leaderboard/constants/index.ts @@ -1,33 +1,57 @@ -import { BLOCK_TIME_MS } from '@tangle-network/dapp-config'; - export enum BadgeEnum { - VALIDATOR = 'VALIDATOR', RESTAKE_DEPOSITOR = 'RESTAKE_DEPOSITOR', RESTAKE_DELEGATOR = 'RESTAKE_DELEGATOR', LIQUID_STAKER = 'LIQUID_STAKER', - NATIVE_RESTAKER = 'NATIVE_RESTAKER', OPERATOR = 'OPERATOR', BLUEPRINT_OWNER = 'BLUEPRINT_OWNER', SERVICE_PROVIDER = 'SERVICE_PROVIDER', JOB_CALLER = 'JOB_CALLER', - NOMINATOR = 'NOMINATOR', } export const BADGE_ICON_RECORD = { [BadgeEnum.LIQUID_STAKER]: '💧', - [BadgeEnum.NATIVE_RESTAKER]: '💎', [BadgeEnum.OPERATOR]: '🛠️', [BadgeEnum.RESTAKE_DELEGATOR]: '💰', [BadgeEnum.RESTAKE_DEPOSITOR]: '💸', - [BadgeEnum.VALIDATOR]: '🔐', [BadgeEnum.BLUEPRINT_OWNER]: '🏗️', [BadgeEnum.SERVICE_PROVIDER]: '💻', [BadgeEnum.JOB_CALLER]: '💼', - [BadgeEnum.NOMINATOR]: '🗳️', } as const satisfies Record; -export const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; +export enum RoleFilterEnum { + OPERATOR = 'OPERATOR', + RESTAKER = 'RESTAKER', + DEVELOPER = 'DEVELOPER', + CUSTOMER = 'CUSTOMER', +} -export const BLOCK_COUNT_IN_ONE_DAY = Math.floor(ONE_DAY_IN_MS / BLOCK_TIME_MS); +export const ROLE_FILTER_LABELS: Record = { + [RoleFilterEnum.OPERATOR]: 'Operator', + [RoleFilterEnum.RESTAKER]: 'Restaker', + [RoleFilterEnum.DEVELOPER]: 'Developer', + [RoleFilterEnum.CUSTOMER]: 'Customer', +}; + +export const ROLE_FILTER_ICONS: Record = { + [RoleFilterEnum.OPERATOR]: '🛠️', + [RoleFilterEnum.RESTAKER]: '💰', + [RoleFilterEnum.DEVELOPER]: '🏗️', + [RoleFilterEnum.CUSTOMER]: '💼', +}; + +export const ROLE_TO_BADGES: Record = { + [RoleFilterEnum.OPERATOR]: [BadgeEnum.OPERATOR], + [RoleFilterEnum.RESTAKER]: [ + BadgeEnum.RESTAKE_DEPOSITOR, + BadgeEnum.RESTAKE_DELEGATOR, + BadgeEnum.LIQUID_STAKER, + ], + [RoleFilterEnum.DEVELOPER]: [BadgeEnum.BLUEPRINT_OWNER], + [RoleFilterEnum.CUSTOMER]: [BadgeEnum.JOB_CALLER], +}; + +export const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; -export const BLOCK_COUNT_IN_SEVEN_DAYS = BLOCK_COUNT_IN_ONE_DAY * 7; +// Envio uses timestamps instead of block numbers for filtering +export const SECONDS_IN_ONE_DAY = 24 * 60 * 60; +export const SEVEN_DAYS_IN_SECONDS = 7 * SECONDS_IN_ONE_DAY; diff --git a/apps/leaderboard/src/features/leaderboard/queries/accountIdentitiesQuery.ts b/apps/leaderboard/src/features/leaderboard/queries/accountIdentitiesQuery.ts deleted file mode 100644 index a1284d4373..0000000000 --- a/apps/leaderboard/src/features/leaderboard/queries/accountIdentitiesQuery.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - getMultipleAccountInfo, - IdentityType, -} from '@tangle-network/tangle-shared-ui/utils/polkadot/identity'; -import { useQuery } from '@tanstack/react-query'; -import { ACCOUNT_IDENTITIES_QUERY_KEY } from '../../../constants/query'; -import { getRpcEndpoint } from '../../../utils/getRpcEndpoint'; -import { Account } from '../types'; - -const fetcher = async (accounts: Pick[]) => { - const { testnetRpc, mainnetRpc } = getRpcEndpoint('ALL'); - - const testnetAccounts: string[] = []; - const mainnetAccounts: string[] = []; - - accounts.forEach((account) => { - if (account.network === 'TESTNET') { - testnetAccounts.push(account.id); - } else { - mainnetAccounts.push(account.id); - } - }); - - const [testnetIdentities, mainnetIdentities] = await Promise.all([ - testnetAccounts.length > 0 - ? getMultipleAccountInfo(testnetRpc, testnetAccounts) - : Promise.resolve([]), - mainnetAccounts.length > 0 - ? getMultipleAccountInfo(mainnetRpc, mainnetAccounts) - : Promise.resolve([]), - ]); - - const identityMap = new Map(); - - testnetIdentities.forEach((identity, idx) => { - const accountId = testnetAccounts.at(idx); - if (identity && accountId) { - identityMap.set(accountId, identity); - } - }); - - mainnetIdentities.forEach((identity, idx) => { - const accountId = mainnetAccounts.at(idx); - if (identity && accountId) { - identityMap.set(accountId, identity); - } - }); - - return identityMap; -}; - -export function useAccountIdentities( - accounts: Pick[], -) { - return useQuery({ - queryKey: [ACCOUNT_IDENTITIES_QUERY_KEY, accounts], - queryFn: () => fetcher(accounts), - enabled: accounts.length > 0, - staleTime: Infinity, - }); -} diff --git a/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts b/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts index 9c76a8a32a..c5ebcb3061 100644 --- a/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts +++ b/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts @@ -1,133 +1,244 @@ -import { - Evaluate, - SafeNestedType, -} from '@tangle-network/dapp-types/utils/types'; -import { graphql } from '@tangle-network/tangle-shared-ui/graphql'; -import { - LeaderboardTableDocumentQuery, - NetworkType, -} from '@tangle-network/tangle-shared-ui/graphql/graphql'; -import { executeGraphQL } from '@tangle-network/tangle-shared-ui/utils/executeGraphQL'; +import { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql'; import { useQuery } from '@tanstack/react-query'; import { LEADERBOARD_QUERY_KEY } from '../../../constants/query'; +import { RoleFilterEnum } from '../constants'; +// Team accounts to exclude from leaderboard (EVM addresses) const TEAM_ACCOUNTS = [ - '5CJFrNyjRahyb7kcn8HH3LPJRaZf2aq6jguk5kx5V5Aa6rXh', - '5H9Ahg236YVtzKnsPp5kokY8qswWNoY65dWrjS3znxVwkaue', - '5E4ixheSH99qbZxXYSLt242bc933rYJ3XrXFt34d2ViVkFZY', - '5FjXDSpyiLbST4PpYzX399vymhHYhxKCP8BNhLBEmLfrUYNv', - '5Dqf9U5dgQ9GLqdfaxXGjpZf9af1sCV8UrnpRgqJPbe3wCwX', + '0x0000000000000000000000000000000000000000', // Placeholder - update with actual team addresses ] as const; -export type LeaderboardAccountNodeType = Evaluate< - SafeNestedType ->; +/** + * PointsAccount node from NVO indexer + */ +export interface LeaderboardAccountNodeType { + id: string; + totalPoints: string; + totalMainnetPoints: string; + totalTestnetPoints: string; + leaderboardPoints: string; + updatedAt: string; + snapshots: Array<{ + id: string; + blockNumber: string; + timestamp: string; + totalPoints: string; + }>; +} + +/** + * Delegator data for activity badges + */ +export interface DelegatorActivityData { + id: string; + totalDeposited: string; + totalDelegated: string; + assetPositions: Array<{ + id: string; + token: string; + totalDeposited: string; + }>; + delegations: Array<{ + id: string; + operator: { id: string }; + token: string; + shares: string; + }>; + liquidVaultPositions: Array<{ + id: string; + shares: string; + }>; +} + +/** + * Combined account data with activity information + */ +export interface LeaderboardAccountWithActivity + extends LeaderboardAccountNodeType { + delegator?: DelegatorActivityData; + isOperator: boolean; + blueprintCount: number; + serviceCount: number; + jobCallCount: number; +} + +interface LeaderboardQueryResponse { + PointsAccount: LeaderboardAccountNodeType[]; +} + +interface ActivityQueryResponse { + Delegator_by_pk: DelegatorActivityData | null; + Operator: Array<{ id: string }>; + Blueprint: Array<{ id: string; owner: string }>; + Service: Array<{ id: string; owner: string }>; + JobCall: Array<{ id: string; caller: string }>; +} + +const getEndpoint = (network: NetworkType): string => { + if (network === 'MAINNET') { + return ( + import.meta.env.VITE_ENVIO_MAINNET_ENDPOINT || + 'http://localhost:8080/v1/graphql' + ); + } + return ( + import.meta.env.VITE_ENVIO_TESTNET_ENDPOINT || + 'http://localhost:8080/v1/graphql' + ); +}; -const LeaderboardQueryDocument = graphql(/* GraphQL */ ` - query LeaderboardTableDocument( - $first: Int! +const LEADERBOARD_QUERY = ` + query LeaderboardQuery( + $limit: Int! $offset: Int! - $blockNumberSevenDaysAgo: Int! - $teamAccounts: [String!]! + $timestampSevenDaysAgo: numeric! $accountIdQuery: String ) { - accounts( - first: $first + PointsAccount( + limit: $limit offset: $offset - orderBy: [TOTAL_POINTS_DESC] - filter: { - id: { notIn: $teamAccounts, includesInsensitive: $accountIdQuery } - } + order_by: { leaderboardPoints: desc } + where: { id: { _ilike: $accountIdQuery } } ) { - nodes { + id + totalPoints + totalMainnetPoints + totalTestnetPoints + leaderboardPoints + updatedAt + snapshots( + order_by: { blockNumber: asc } + where: { timestamp: { _gte: $timestampSevenDaysAgo } } + ) { id + blockNumber + timestamp totalPoints - totalMainnetPoints - totalTestnetPoints - isValidator - isNominator - delegators(first: 1) { - nodes { - deposits { - totalCount - } - delegations { - totalCount - nodes { - assetId - } - } - } - } - operators { - totalCount - } - lstPoolMembers { - totalCount - } - blueprints { - totalCount - } - services { - totalCount - } - jobCalls { - totalCount - } - testnetTaskCompletions(first: 1) { - nodes { - hasDepositedThreeAssets - hasDelegatedAssets - hasNominated - hasLiquidStaked - hasNativeRestaked - hasBonusPoints - } - } - snapshots( - orderBy: BLOCK_NUMBER_ASC - filter: { - blockNumber: { greaterThanOrEqualTo: $blockNumberSevenDaysAgo } - } - ) { - totalCount - nodes { - blockNumber - totalPoints - } - } - createdAt - createdAtTimestamp - lastUpdatedAt - lastUpdatedAtTimestamp } - totalCount } } -`); +`; -const fetcher = async ( +const ACTIVITY_QUERY = ` + query AccountActivity($accountId: String!) { + Delegator_by_pk(id: $accountId) { + id + totalDeposited + totalDelegated + assetPositions { + id + token + totalDeposited + } + delegations { + id + operator { id } + token + shares + } + liquidVaultPositions { + id + shares + } + } + Operator(where: { id: { _eq: $accountId } }) { + id + } + Blueprint(where: { owner: { _eq: $accountId } }) { + id + owner + } + Service(where: { owner: { _eq: $accountId } }) { + id + owner + } + JobCall(where: { caller: { _eq: $accountId } }) { + id + caller + } + } +`; + +const fetchLeaderboard = async ( network: NetworkType, - first: number, + limit: number, offset: number, - blockNumberSevenDaysAgo: number, + timestampSevenDaysAgo: number, accountIdQuery?: string, -) => { - const result = await executeGraphQL(network, LeaderboardQueryDocument, { - first, - offset, - blockNumberSevenDaysAgo, - teamAccounts: TEAM_ACCOUNTS.slice(), - accountIdQuery, +): Promise<{ nodes: LeaderboardAccountNodeType[]; totalCount: number }> => { + const endpoint = getEndpoint(network); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + query: LEADERBOARD_QUERY, + variables: { + limit, + offset, + timestampSevenDaysAgo, + accountIdQuery: accountIdQuery ? `%${accountIdQuery}%` : '%%', + }, + }), }); - return result.data.accounts; + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const result = (await response.json()) as { data: LeaderboardQueryResponse }; + + // Filter out team accounts + const filteredAccounts = result.data.PointsAccount.filter( + (account) => + !TEAM_ACCOUNTS.includes( + account.id.toLowerCase() as (typeof TEAM_ACCOUNTS)[number], + ), + ); + + return { + nodes: filteredAccounts, + totalCount: filteredAccounts.length, + }; }; +const fetchAccountActivity = async ( + network: NetworkType, + accountId: string, +): Promise => { + const endpoint = getEndpoint(network); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + query: ACTIVITY_QUERY, + variables: { accountId }, + }), + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const result = (await response.json()) as { data: ActivityQueryResponse }; + return result.data; +}; + +// Auto-refresh interval in milliseconds (10 seconds) +const LEADERBOARD_REFETCH_INTERVAL = 10_000; + export function useLeaderboard( network: NetworkType, first: number, offset: number, - blockNumberSevenDaysAgo: number, + timestampSevenDaysAgo: number, accountIdQuery?: string, ) { return useQuery({ @@ -136,12 +247,132 @@ export function useLeaderboard( network, first, offset, - blockNumberSevenDaysAgo, + timestampSevenDaysAgo, accountIdQuery, ], queryFn: () => - fetcher(network, first, offset, blockNumberSevenDaysAgo, accountIdQuery), - enabled: first > 0 && offset >= 0 && blockNumberSevenDaysAgo > 0, + fetchLeaderboard( + network, + first, + offset, + timestampSevenDaysAgo, + accountIdQuery, + ), + enabled: first > 0 && offset >= 0 && timestampSevenDaysAgo > 0, placeholderData: (prev) => prev, + refetchInterval: LEADERBOARD_REFETCH_INTERVAL, + }); +} + +export function useAccountActivity(network: NetworkType, accountId: string) { + return useQuery({ + queryKey: ['accountActivity', network, accountId], + queryFn: () => fetchAccountActivity(network, accountId), + enabled: !!accountId, + staleTime: Infinity, + }); +} + +const ROLE_ACCOUNTS_QUERY = ` + query RoleAccounts { + Operator { + id + } + Delegator(where: { _or: [ + { totalDeposited: { _gt: "0" } }, + { totalDelegated: { _gt: "0" } } + ]}) { + id + } + Blueprint { + owner + } + JobCall { + caller + } + } +`; + +interface RoleAccountsResponse { + Operator: Array<{ id: string }>; + Delegator: Array<{ id: string }>; + Blueprint: Array<{ owner: string }>; + JobCall: Array<{ caller: string }>; +} + +export interface RoleAccountsData { + operators: Set; + restakers: Set; + developers: Set; + customers: Set; +} + +const fetchRoleAccounts = async ( + network: NetworkType, +): Promise => { + const endpoint = getEndpoint(network); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + query: ROLE_ACCOUNTS_QUERY, + }), + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const result = (await response.json()) as { data: RoleAccountsResponse }; + + return { + operators: new Set(result.data.Operator.map((o) => o.id.toLowerCase())), + restakers: new Set(result.data.Delegator.map((d) => d.id.toLowerCase())), + developers: new Set( + result.data.Blueprint.map((b) => b.owner.toLowerCase()), + ), + customers: new Set(result.data.JobCall.map((j) => j.caller.toLowerCase())), + }; +}; + +export const getAccountIdsForRoles = ( + roleAccounts: RoleAccountsData, + selectedRoles: RoleFilterEnum[], +): Set => { + if (selectedRoles.length === 0) { + return new Set(); + } + + const accountIds = new Set(); + + for (const role of selectedRoles) { + switch (role) { + case RoleFilterEnum.OPERATOR: + roleAccounts.operators.forEach((id) => accountIds.add(id)); + break; + case RoleFilterEnum.RESTAKER: + roleAccounts.restakers.forEach((id) => accountIds.add(id)); + break; + case RoleFilterEnum.DEVELOPER: + roleAccounts.developers.forEach((id) => accountIds.add(id)); + break; + case RoleFilterEnum.CUSTOMER: + roleAccounts.customers.forEach((id) => accountIds.add(id)); + break; + } + } + + return accountIds; +}; + +export function useRoleAccounts(network: NetworkType) { + return useQuery({ + queryKey: ['roleAccounts', network], + queryFn: () => fetchRoleAccounts(network), + staleTime: 30_000, }); } diff --git a/apps/leaderboard/src/features/leaderboard/types/index.ts b/apps/leaderboard/src/features/leaderboard/types/index.ts index 3baec86b5e..33481a9ca5 100644 --- a/apps/leaderboard/src/features/leaderboard/types/index.ts +++ b/apps/leaderboard/src/features/leaderboard/types/index.ts @@ -1,5 +1,4 @@ import type { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql'; -import type { IdentityType } from '@tangle-network/tangle-shared-ui/utils/polkadot/identity'; import type { BadgeEnum } from '../constants'; export interface PointsBreakdown { @@ -11,24 +10,15 @@ export interface PointsBreakdown { export interface AccountActivity { depositCount: number; delegationCount: number; - liquidStakingPoolCount: number; + liquidVaultPositionCount: number; blueprintCount: number; serviceCount: number; jobCallCount: number; } -export interface TestnetTaskCompletion { - depositedThreeAssets: boolean; - delegatedAssets: boolean; - liquidStaked: boolean; - nominated: boolean; - nativeRestaked: boolean; - bonus: boolean; - completionPercentage: number; -} - export interface PointsHistory { blockNumber: number; + timestamp: number; points: bigint; } @@ -39,12 +29,8 @@ export interface Account { pointsBreakdown: PointsBreakdown; badges: BadgeEnum[]; activity: AccountActivity; - testnetTaskCompletion?: TestnetTaskCompletion; pointsHistory: PointsHistory[]; - createdAt: number; - createdAtTimestamp: Date | null | undefined; - lastUpdatedAt: number; - lastUpdatedAtTimestamp: Date | null | undefined; - identity: IdentityType | null | undefined; + updatedAt: number; + updatedAtTimestamp: Date | null; network: NetworkType; } diff --git a/apps/leaderboard/src/features/leaderboard/utils/createAccountExplorerUrl.ts b/apps/leaderboard/src/features/leaderboard/utils/createAccountExplorerUrl.ts index d9828fa383..b9f2fe114a 100644 --- a/apps/leaderboard/src/features/leaderboard/utils/createAccountExplorerUrl.ts +++ b/apps/leaderboard/src/features/leaderboard/utils/createAccountExplorerUrl.ts @@ -1,24 +1,13 @@ import { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql'; -import { - TANGLE_MAINNET_NETWORK, - TANGLE_TESTNET_NATIVE_NETWORK, -} from '@tangle-network/ui-components/constants/networks'; -import { - EvmAddress, - SubstrateAddress, -} from '@tangle-network/ui-components/types/address'; + +// EVM block explorer URLs for Tangle networks +const MAINNET_EXPLORER = 'https://explorer.tangle.tools'; +const TESTNET_EXPLORER = 'https://testnet-explorer.tangle.tools'; export const createAccountExplorerUrl = ( - address: SubstrateAddress | EvmAddress, + address: string, network: NetworkType, -) => { - switch (network) { - case 'MAINNET': - return TANGLE_MAINNET_NETWORK.createExplorerAccountUrl(address); - case 'TESTNET': - return TANGLE_TESTNET_NATIVE_NETWORK.createExplorerAccountUrl(address); - default: - console.error(`Unsupported network: ${network}`); - return null; - } +): string => { + const baseUrl = network === 'MAINNET' ? MAINNET_EXPLORER : TESTNET_EXPLORER; + return `${baseUrl}/address/${address}`; }; diff --git a/apps/leaderboard/src/features/leaderboard/utils/formatDisplayBlockNumber.ts b/apps/leaderboard/src/features/leaderboard/utils/formatDisplayBlockNumber.ts deleted file mode 100644 index 62e27c2710..0000000000 --- a/apps/leaderboard/src/features/leaderboard/utils/formatDisplayBlockNumber.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { formatDistanceToNow } from 'date-fns'; - -export const formatDisplayBlockNumber = ( - blockNumber: number, - blockTimestamp: Date | null | undefined, -) => { - if (blockTimestamp) { - return formatDistanceToNow(blockTimestamp, { addSuffix: true }); - } - - return `Block: #${blockNumber}`; -}; diff --git a/apps/leaderboard/src/features/leaderboard/utils/processLeaderboardRecord.ts b/apps/leaderboard/src/features/leaderboard/utils/processLeaderboardRecord.ts index 62e1e1fd7c..1b1e135b15 100644 --- a/apps/leaderboard/src/features/leaderboard/utils/processLeaderboardRecord.ts +++ b/apps/leaderboard/src/features/leaderboard/utils/processLeaderboardRecord.ts @@ -1,198 +1,167 @@ import { ZERO_BIG_INT } from '@tangle-network/dapp-config/constants'; -import type { IdentityType } from '@tangle-network/tangle-shared-ui/utils/polkadot/identity'; import { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql'; -import { toBigInt } from '@tangle-network/ui-components'; import find from 'lodash/find'; import findLast from 'lodash/findLast'; import { BadgeEnum } from '../constants'; -import type { LeaderboardAccountNodeType } from '../queries'; -import { Account, TestnetTaskCompletion } from '../types'; - -const calculateLastSevenDaysPoints = (record: LeaderboardAccountNodeType) => { - const firstSnapshot = find(record.snapshots.nodes, (snapshot) => { - return snapshot !== null; - }); - - const lastSnapshot = findLast(record.snapshots.nodes, (snapshot) => { - return snapshot !== null; - }); - - if (!firstSnapshot || !lastSnapshot) { +import type { PointsHistory } from '../types'; +import type { + LeaderboardAccountNodeType, + DelegatorActivityData, +} from '../queries'; +import { Account } from '../types'; + +/** + * Activity data structure for badge determination + */ +export interface ActivityData { + delegator?: DelegatorActivityData; + isOperator: boolean; + blueprintCount: number; + serviceCount: number; + jobCallCount: number; +} + +const safeBigInt = (value: string | undefined | null): bigint => { + if (!value) { + return ZERO_BIG_INT; + } + try { + return BigInt(value); + } catch { + console.error('Failed to convert to bigint:', value); return ZERO_BIG_INT; } +}; - const firstSnapshotTotalPointsResult = toBigInt(firstSnapshot.totalPoints); +const calculateLastSevenDaysPoints = ( + record: LeaderboardAccountNodeType, +): bigint => { + const snapshots = record.snapshots; - const lastSnapshotTotalPointsResult = toBigInt(lastSnapshot.totalPoints); + if (!snapshots || snapshots.length === 0) { + return ZERO_BIG_INT; + } - if ( - firstSnapshotTotalPointsResult.error !== null || - lastSnapshotTotalPointsResult.error !== null - ) { - console.error( - 'Failed to convert snapshot.totalPoints to bigint', - firstSnapshot, - lastSnapshot, - ); + const firstSnapshot = find(snapshots, (snapshot) => snapshot !== null); + const lastSnapshot = findLast(snapshots, (snapshot) => snapshot !== null); + if (!firstSnapshot || !lastSnapshot) { return ZERO_BIG_INT; } - return ( - lastSnapshotTotalPointsResult.result - firstSnapshotTotalPointsResult.result - ); + const firstPoints = safeBigInt(firstSnapshot.totalPoints); + const lastPoints = safeBigInt(lastSnapshot.totalPoints); + + return lastPoints - firstPoints; }; -const determineBadges = (record: LeaderboardAccountNodeType): BadgeEnum[] => { +const determineBadges = (activity?: ActivityData): BadgeEnum[] => { const badges: BadgeEnum[] = []; - const hasDeposited = record.delegators?.nodes.find( - (node) => node?.deposits.totalCount && node.deposits.totalCount > 0, - ); + if (!activity) { + return badges; + } + + const { delegator, isOperator, blueprintCount, serviceCount, jobCallCount } = + activity; + + // Check for deposits + const hasDeposited = + delegator?.assetPositions?.some( + (pos) => safeBigInt(pos.totalDeposited) > ZERO_BIG_INT, + ) ?? false; if (hasDeposited) { badges.push(BadgeEnum.RESTAKE_DEPOSITOR); } - const hasDelegated = record.delegators?.nodes.find( - (node) => node?.delegations.totalCount && node.delegations.totalCount > 0, - ); + // Check for delegations + const hasDelegated = + delegator?.delegations?.some( + (del) => safeBigInt(del.shares) > ZERO_BIG_INT, + ) ?? false; if (hasDelegated) { badges.push(BadgeEnum.RESTAKE_DELEGATOR); } - const hasLiquidStaked = record.lstPoolMembers.totalCount > 0; - if (hasLiquidStaked) { - badges.push(BadgeEnum.LIQUID_STAKER); - } + // Check for liquid vault positions + const hasLiquidVault = + delegator?.liquidVaultPositions?.some( + (pos) => safeBigInt(pos.shares) > ZERO_BIG_INT, + ) ?? false; - const hasNativeRestaked = record.delegators?.nodes.find( - (node) => - node?.delegations.nodes && - node.delegations.nodes.find( - (delegation) => - delegation?.assetId && delegation.assetId === `${ZERO_BIG_INT}`, - ), - ); - - if (hasNativeRestaked) { - badges.push(BadgeEnum.NATIVE_RESTAKER); + if (hasLiquidVault) { + badges.push(BadgeEnum.LIQUID_STAKER); } - const isOperator = record.operators.totalCount > 0; + // Operator badge if (isOperator) { badges.push(BadgeEnum.OPERATOR); } - const isBlueprintOwner = record.blueprints.totalCount > 0; - if (isBlueprintOwner) { + // Blueprint owner badge + if (blueprintCount > 0) { badges.push(BadgeEnum.BLUEPRINT_OWNER); } - const isServiceProvider = record.services.totalCount > 0; - if (isServiceProvider) { + // Service provider badge + if (serviceCount > 0) { badges.push(BadgeEnum.SERVICE_PROVIDER); } - const isJobCaller = record.jobCalls.totalCount > 0; - if (isJobCaller) { + // Job caller badge + if (jobCallCount > 0) { badges.push(BadgeEnum.JOB_CALLER); } - const isValidator = record.isValidator ?? false; - if (isValidator) { - badges.push(BadgeEnum.VALIDATOR); - } - - const isNominator = record.isNominator ?? false; - if (isNominator) { - badges.push(BadgeEnum.NOMINATOR); - } - return badges; }; -const calculateActivityCounts = (record: LeaderboardAccountNodeType) => { - const depositCount = record.delegators?.nodes.reduce((acc, node) => { - if (!node) { - return acc; - } - - return acc + node.deposits.totalCount; - }, 0); - - const delegationCount = record.delegators?.nodes.reduce((acc, node) => { - if (!node) { - return acc; - } +const calculateActivityCounts = (activity?: ActivityData) => { + if (!activity) { + return { + depositCount: 0, + delegationCount: 0, + liquidVaultPositionCount: 0, + blueprintCount: 0, + serviceCount: 0, + jobCallCount: 0, + }; + } - return acc + node.delegations.totalCount; - }, 0); + const { delegator, blueprintCount, serviceCount, jobCallCount } = activity; return { - blueprintCount: record.blueprints.totalCount, - depositCount, - delegationCount, - liquidStakingPoolCount: record.lstPoolMembers.totalCount, - serviceCount: record.services.totalCount, - jobCallCount: record.jobCalls.totalCount, + depositCount: delegator?.assetPositions?.length ?? 0, + delegationCount: delegator?.delegations?.length ?? 0, + liquidVaultPositionCount: delegator?.liquidVaultPositions?.length ?? 0, + blueprintCount, + serviceCount, + jobCallCount, }; }; -const processTestnetTaskCompletion = (record: LeaderboardAccountNodeType) => { - const testnetTask = record.testnetTaskCompletions.nodes.find( - (node) => node !== null, - ); - - if (!testnetTask) { - return undefined; +const processPointsHistory = ( + record: LeaderboardAccountNodeType, +): PointsHistory[] => { + if (!record.snapshots) { + return []; } - const testnetTaskCompletion: Omit< - TestnetTaskCompletion, - 'completionPercentage' - > = { - depositedThreeAssets: !!testnetTask.hasDepositedThreeAssets, - delegatedAssets: !!testnetTask.hasDelegatedAssets, - liquidStaked: !!testnetTask.hasLiquidStaked, - nominated: !!testnetTask.hasNominated, - nativeRestaked: !!testnetTask.hasNativeRestaked, - bonus: !!testnetTask.hasBonusPoints, - }; - - return { - ...testnetTaskCompletion, - completionPercentage: - (Object.values(testnetTaskCompletion).filter(Boolean).length / - Object.keys(testnetTaskCompletion).length) * - 100, - }; -}; - -const processPointsHistory = (record: LeaderboardAccountNodeType) => { - return record.snapshots.nodes + return record.snapshots .map((snapshot) => { if (!snapshot) { return null; } - const snapshotPointsResult = toBigInt(snapshot.totalPoints); - - if (snapshotPointsResult.error !== null) { - console.error( - 'Failed to convert snapshot.totalPoints to bigint', - snapshot, - ); - return null; - } - return { - blockNumber: snapshot.blockNumber, - points: snapshotPointsResult.result, + blockNumber: Number(snapshot.blockNumber), + timestamp: Number(snapshot.timestamp), + points: safeBigInt(snapshot.totalPoints), }; }) - .filter((item) => item !== null); + .filter((item): item is PointsHistory => item !== null); }; export const processLeaderboardRecord = ( @@ -200,58 +169,40 @@ export const processLeaderboardRecord = ( index: number, pageIndex: number, pageSize: number, - identity: IdentityType | null | undefined, + activity?: ActivityData, + network: NetworkType = 'MAINNET', ): Account | null => { if (!record) { return null; } - const totalPointsResult = toBigInt(record.totalPoints); - - if (totalPointsResult.error !== null) { - console.error('Failed to convert totalPoints to bigint', record); - return null; - } - - const totalMainnetPointsResult = toBigInt(record.totalMainnetPoints); - - if (totalMainnetPointsResult.error !== null) { - console.error('Failed to convert totalMainnetPoints to bigint', record); - return null; - } - - const totalTestnetPointsResult = toBigInt(record.totalTestnetPoints); - - if (totalTestnetPointsResult.error !== null) { - console.error('Failed to convert totalTestnetPoints to bigint', record); - return null; - } - + const totalPoints = safeBigInt(record.totalPoints); + const totalMainnetPoints = safeBigInt(record.totalMainnetPoints); + const totalTestnetPoints = safeBigInt(record.totalTestnetPoints); const lastSevenDays = calculateLastSevenDaysPoints(record); - const badges = determineBadges(record); - const activity = calculateActivityCounts(record); - const testnetTaskCompletion = processTestnetTaskCompletion(record); + const badges = determineBadges(activity); + const activityCounts = calculateActivityCounts(activity); const pointsHistory = processPointsHistory(record); + // updatedAt is a timestamp string from Envio + const updatedAtTimestamp = record.updatedAt + ? new Date(Number(record.updatedAt) * 1000) + : null; + return { id: record.id, rank: pageIndex * pageSize + index + 1, - totalPoints: totalPointsResult.result, + totalPoints, pointsBreakdown: { - mainnet: totalMainnetPointsResult.result, - testnet: totalTestnetPointsResult.result, + mainnet: totalMainnetPoints, + testnet: totalTestnetPoints, lastSevenDays, }, badges, - activity, - testnetTaskCompletion, + activity: activityCounts, pointsHistory, - createdAt: record.createdAt, - createdAtTimestamp: record.createdAtTimestamp, - lastUpdatedAt: record.lastUpdatedAt, - lastUpdatedAtTimestamp: record.lastUpdatedAtTimestamp, - identity, - // TODO: This should fetch from the API once the server supports multi-chain - network: 'TESTNET' as NetworkType, + updatedAt: Number(record.updatedAt) || 0, + updatedAtTimestamp, + network, } satisfies Account; }; diff --git a/apps/leaderboard/src/queries/index.ts b/apps/leaderboard/src/queries/index.ts index dcf6ca941b..62cc88d730 100644 --- a/apps/leaderboard/src/queries/index.ts +++ b/apps/leaderboard/src/queries/index.ts @@ -1 +1 @@ -export * from './latestFinalizedBlockQuery'; +export * from './latestTimestampQuery'; diff --git a/apps/leaderboard/src/queries/latestFinalizedBlockQuery.ts b/apps/leaderboard/src/queries/latestFinalizedBlockQuery.ts deleted file mode 100644 index 3d21fd0573..0000000000 --- a/apps/leaderboard/src/queries/latestFinalizedBlockQuery.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { getApiPromise } from '@tangle-network/tangle-shared-ui/utils/polkadot/api'; -import { useQuery } from '@tanstack/react-query'; -import { LATEST_FINALIZED_BLOCK_QUERY_KEY } from '../constants/query'; -import { Network } from '../types'; -import { getRpcEndpoint } from '../utils/getRpcEndpoint'; - -type UseLatestFinalizedBlockResult = - TNetwork extends 'all' - ? { - mainnetBlock: number; - testnetBlock: number; - } - : TNetwork extends 'TESTNET' - ? { - mainnetBlock: never; - testnetBlock: number; - } - : TNetwork extends 'MAINNET' - ? { - mainnetBlock: number; - testnetBlock: never; - } - : never; - -const fetcher = async ( - network: TNetwork, -): Promise> => { - const { testnetRpc, mainnetRpc } = getRpcEndpoint(network); - - const getBlockNumber = async (rpc: string) => { - const api = await getApiPromise(rpc); - // no blockHash is specified, so we retrieve the latest - const { block } = await api.rpc.chain.getBlock(); - - return block.header.number.toNumber(); - }; - - const [testnetBlock, mainnetBlock] = await Promise.all([ - testnetRpc ? getBlockNumber(testnetRpc) : null, - mainnetRpc ? getBlockNumber(mainnetRpc) : null, - ]); - - return { - testnetBlock, - mainnetBlock, - } as UseLatestFinalizedBlockResult; -}; - -export function useLatestFinalizedBlock( - network: TNetwork, -) { - return useQuery({ - queryKey: [LATEST_FINALIZED_BLOCK_QUERY_KEY, network], - queryFn: () => fetcher(network), - staleTime: Infinity, - }); -} diff --git a/apps/leaderboard/src/queries/latestTimestampQuery.ts b/apps/leaderboard/src/queries/latestTimestampQuery.ts new file mode 100644 index 0000000000..0318720f1f --- /dev/null +++ b/apps/leaderboard/src/queries/latestTimestampQuery.ts @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query'; +import { LATEST_TIMESTAMP_QUERY_KEY } from '../constants/query'; +import { Network } from '../types'; + +type UseLatestTimestampResult = TNetwork extends 'all' + ? { + mainnetTimestamp: number; + testnetTimestamp: number; + } + : TNetwork extends 'TESTNET' + ? { + mainnetTimestamp: never; + testnetTimestamp: number; + } + : TNetwork extends 'MAINNET' + ? { + mainnetTimestamp: number; + testnetTimestamp: never; + } + : never; + +/** + * Returns current timestamps for use with Envio queries. + * Envio uses timestamps (in seconds) instead of block numbers for filtering. + */ +const fetcher = async ( + network: TNetwork, +): Promise> => { + // Get current timestamp in seconds (Envio uses Unix timestamps) + const currentTimestamp = Math.floor(Date.now() / 1000); + + if (network === 'all') { + return { + testnetTimestamp: currentTimestamp, + mainnetTimestamp: currentTimestamp, + } as UseLatestTimestampResult; + } + + if (network === 'TESTNET') { + return { + testnetTimestamp: currentTimestamp, + } as UseLatestTimestampResult; + } + + return { + mainnetTimestamp: currentTimestamp, + } as UseLatestTimestampResult; +}; + +export function useLatestTimestamp( + network: TNetwork, +) { + return useQuery({ + queryKey: [LATEST_TIMESTAMP_QUERY_KEY, network], + queryFn: () => fetcher(network), + staleTime: Infinity, + }); +} diff --git a/apps/leaderboard/src/types/index.ts b/apps/leaderboard/src/types/index.ts index b53cb0dcbe..98247be8ef 100644 --- a/apps/leaderboard/src/types/index.ts +++ b/apps/leaderboard/src/types/index.ts @@ -1,3 +1,3 @@ import { NetworkType } from '@tangle-network/tangle-shared-ui/graphql/graphql'; -export type Network = 'ALL' | NetworkType; +export type Network = 'all' | NetworkType; diff --git a/apps/leaderboard/src/utils/getRpcEndpoint.ts b/apps/leaderboard/src/utils/getRpcEndpoint.ts deleted file mode 100644 index a023f216b8..0000000000 --- a/apps/leaderboard/src/utils/getRpcEndpoint.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - TANGLE_MAINNET_WS_RPC_ENDPOINT, - TANGLE_TESTNET_WS_RPC_ENDPOINT, -} from '@tangle-network/dapp-config'; -import { Network } from '../types'; - -type GetRpcEndpointResult = TNetwork extends 'TESTNET' - ? { - testnetRpc: typeof TANGLE_TESTNET_WS_RPC_ENDPOINT; - mainnetRpc: undefined; - } - : TNetwork extends 'MAINNET' - ? { - testnetRpc: undefined; - mainnetRpc: typeof TANGLE_MAINNET_WS_RPC_ENDPOINT; - } - : TNetwork extends 'ALL' - ? { - testnetRpc: typeof TANGLE_TESTNET_WS_RPC_ENDPOINT; - mainnetRpc: typeof TANGLE_MAINNET_WS_RPC_ENDPOINT; - } - : never; - -export function getRpcEndpoint( - network: TNetwork, -): GetRpcEndpointResult { - switch (network) { - case 'TESTNET': - return { - testnetRpc: TANGLE_TESTNET_WS_RPC_ENDPOINT, - mainnetRpc: undefined, - } as GetRpcEndpointResult; - case 'MAINNET': - return { - testnetRpc: undefined, - mainnetRpc: TANGLE_MAINNET_WS_RPC_ENDPOINT, - } as GetRpcEndpointResult; - case 'ALL': - return { - testnetRpc: TANGLE_TESTNET_WS_RPC_ENDPOINT, - mainnetRpc: TANGLE_MAINNET_WS_RPC_ENDPOINT, - } as GetRpcEndpointResult; - default: - throw new Error(`Invalid network: ${network}`); - } -} diff --git a/apps/tangle-cloud/IMPLEMENTATION_PLAN.md b/apps/tangle-cloud/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..15d93704fd --- /dev/null +++ b/apps/tangle-cloud/IMPLEMENTATION_PLAN.md @@ -0,0 +1,356 @@ +# Tangle Cloud Implementation Plan + +## Gap Analysis: tangle-cloud vs tnt-core + +Based on comprehensive analysis of tnt-core smart contracts and current tangle-cloud implementation. + +--- + +## MISSING USER STORIES BY ROLE + +### A. Blueprint Developers (NEW ROLE - Not yet supported) + +| Priority | User Story | tnt-core Function | Status | +|----------|------------|-------------------|--------| +| P0 | Create new blueprint | `createBlueprint(BlueprintDefinition)` | ❌ Missing | +| P0 | Define job specifications | `BlueprintDefinition.jobs[]` | ❌ Missing | +| P0 | Set pricing model (PayOnce/Subscription/EventDriven) | `BlueprintConfig.pricing` | ❌ Missing | +| P0 | Set membership model (Fixed/Dynamic) | `BlueprintConfig.membership` | ❌ Missing | +| P1 | Update blueprint metadata | `updateBlueprint(blueprintId, metadataUri)` | ❌ Missing | +| P1 | Transfer blueprint ownership | `transferBlueprint(blueprintId, newOwner)` | ❌ Missing | +| P1 | Deactivate blueprint | `deactivateBlueprint(blueprintId)` | ❌ Missing | +| P2 | Deploy custom service manager | `IBlueprintServiceManager` | ❌ Missing | +| P2 | View developer earnings | Payment distribution tracking | ❌ Missing | + +**Required Pages:** +- `/blueprints/create` - Blueprint creation wizard +- `/blueprints/:id/manage` - Blueprint management (owner only) +- `/developer/dashboard` - Developer earnings and stats + +--- + +### B. Operators (Partially Implemented) + +| Priority | User Story | tnt-core Function | Status | +|----------|------------|-------------------|--------| +| ✅ | Register for blueprint | `registerOperator(blueprintId, ...)` | ✅ Done | +| ✅ | Approve service request | `approveService(requestId, restakingPercent)` | ✅ Done | +| ✅ | Reject service request | `rejectService(requestId)` | ✅ Done | +| P0 | Submit job results | `submitResult(serviceId, callId, result)` | ❌ Missing | +| P0 | View pending jobs | Job queue from indexer | ❌ Missing | +| P0 | Claim rewards | `claimRewards()` | ❌ Missing | +| P0 | View earned rewards | `pendingRewards(account)` | ❌ Missing | +| P1 | Update operator preferences | `updateOperatorPreferences(blueprintId, ...)` | ❌ Missing | +| P1 | Set online/offline status | `setOperatorOnline(blueprintId, online)` | ❌ Missing | +| P1 | Unregister from blueprint | `unregisterOperator(blueprintId)` | ❌ Missing | +| P1 | Dispute slashing | `disputeSlash(slashId, reason)` | ❌ Missing | +| P1 | View slashing proposals | Slashing events from indexer | ❌ Missing | +| P2 | Submit aggregated BLS result | `submitAggregatedResult(...)` | ❌ Missing | +| P2 | Join dynamic service | `joinService(serviceId, exposureBps)` | ❌ Missing | +| P2 | Leave dynamic service | `scheduleExit/executeExit` | ❌ Missing | + +**Required Pages:** +- `/operator/jobs` - Pending job queue and result submission +- `/operator/rewards` - Rewards dashboard and claiming +- `/operator/settings` - Preferences, online status, unregister +- `/operator/slashing` - View and dispute slashing proposals + +--- + +### C. Customers/Deployers (Partially Implemented) + +| Priority | User Story | tnt-core Function | Status | +|----------|------------|-------------------|--------| +| ✅ | Deploy service (basic) | `requestService(...)` | ✅ Done | +| ✅ | View running services | Service query | ✅ Done | +| ✅ | Terminate service | `terminateService(serviceId)` | ✅ Done | +| P0 | Submit job to service | `submitJob(serviceId, jobIndex, inputs)` | ❌ Missing | +| P0 | View job results | Job completion events | ❌ Missing | +| P0 | View job history | Historical jobs from indexer | ❌ Missing | +| P1 | Deploy with custom exposure | `requestServiceWithExposure(...)` | ❌ Missing | +| P1 | Deploy with multi-asset security | `requestServiceWithSecurity(...)` | ❌ Missing | +| P1 | Fund subscription service | `fundService(serviceId, amount)` | ❌ Missing | +| P1 | Add/remove permitted callers | `addPermittedCaller/removePermittedCaller` | ❌ Missing | +| P2 | Use RFQ instant deployment | `createServiceFromQuotes(...)` | ❌ Missing | +| P2 | Batch submit jobs | `submitJobs(...)` | ❌ Missing | + +**Required Pages:** +- `/services/:id` - Service detail with job submission +- `/services/:id/jobs` - Job history and results +- `/services/:id/settings` - Permitted callers, funding + +--- + +### D. Delegators/Restakers (NEW ROLE - Not yet supported) + +| Priority | User Story | tnt-core Function | Status | +|----------|------------|-------------------|--------| +| P0 | Deposit native tokens | `deposit()` | ❌ Missing | +| P0 | Deposit ERC20 tokens | `depositERC20(token, amount)` | ❌ Missing | +| P0 | Delegate to operator | `delegate(operator, amount)` | ❌ Missing | +| P0 | View delegations | Delegation queries | ❌ Missing | +| P0 | Undelegate from operator | `scheduleDelegatorUnstake(...)` | ❌ Missing | +| P0 | Withdraw deposits | `scheduleWithdraw/executeWithdraw` | ❌ Missing | +| P0 | Claim rewards | `claimRewards()` | ❌ Missing | +| P1 | Deposit with lock multiplier | `depositWithLock(LockMultiplier)` | ❌ Missing | +| P1 | Choose blueprint exposure | `BlueprintSelectionMode` | ❌ Missing | +| P1 | View slashing impact | Slashing events | ❌ Missing | + +**Required Pages:** +- `/restake` - Deposit/withdraw interface +- `/restake/delegate` - Delegation management +- `/restake/rewards` - Rewards dashboard + +--- + +## IMPLEMENTATION PHASES + +### Phase 1: Core Job & Result Flow (P0) - 2 weeks +**Goal:** Enable end-to-end service usage + +1. **Service Detail Page** (`/services/:id`) + - Job submission form (based on blueprint job schemas) + - Job result display + - Service status and operator list + +2. **Operator Job Queue** (`/operator/jobs`) + - List pending jobs for operator's services + - Result submission form + - Job completion status + +3. **Rewards Dashboard** (`/operator/rewards`, `/restake/rewards`) + - Pending rewards display + - Claim rewards button + - Historical earnings + +**New Hooks Required:** +- `useSubmitJobTx` - Submit job to service +- `useSubmitResultTx` - Operator submits result +- `useClaimRewardsTx` - Claim pending rewards +- `usePendingRewards` - Query pending rewards +- `useJobsByService` - Query jobs for a service +- `useJobsByOperator` - Query jobs operator needs to process + +**New ABIs Required:** +- Jobs ABI (submitJob, submitResult) +- Payments ABI (claimRewards, pendingRewards) + +--- + +### Phase 2: Blueprint Developer Tools (P0-P1) - 2 weeks +**Goal:** Enable blueprint creation and management + +1. **Blueprint Creation Wizard** (`/blueprints/create`) + - Step 1: Basic info (name, description, category) + - Step 2: Job definitions (name, inputs schema, outputs schema) + - Step 3: Pricing configuration + - Step 4: Membership & operator bounds + - Step 5: Registration/request schemas + - Step 6: Source specification (Container/WASM/Native) + - Step 7: Review & deploy + +2. **Blueprint Management** (`/blueprints/:id/manage`) + - Update metadata + - Transfer ownership + - Deactivate blueprint + - View registered operators + - View active services + +3. **Developer Dashboard** (`/developer/dashboard`) + - Owned blueprints list + - Earnings by blueprint + - Service statistics + +**New Hooks Required:** +- `useCreateBlueprintTx` - Create new blueprint +- `useUpdateBlueprintTx` - Update metadata +- `useTransferBlueprintTx` - Transfer ownership +- `useDeactivateBlueprintTx` - Deactivate blueprint +- `useBlueprintsByOwner` - Query owned blueprints + +**New ABIs Required:** +- Blueprints ABI (createBlueprint, updateBlueprint, transferBlueprint, deactivateBlueprint) + +--- + +### Phase 3: Delegator/Restaker Interface (P0) - 2 weeks +**Goal:** Enable staking and delegation + +1. **Restake Dashboard** (`/restake`) + - Deposit native/ERC20 + - View deposits and balances + - Withdraw flow (schedule + execute) + +2. **Delegation Management** (`/restake/delegate`) + - Browse operators with stats + - Delegate to operator + - View active delegations + - Undelegate flow + +3. **Blueprint Exposure Selection** + - All blueprints mode + - Fixed blueprint selection + +**New Hooks Required:** +- `useDepositTx` - Deposit to restaking +- `useWithdrawTx` - Schedule/execute withdraw +- `useDelegateTx` - Delegate to operator +- `useUndelegateTx` - Undelegate from operator +- `useDelegatorInfo` - Query delegator state + +**Note:** Much of this may already exist in tangle-dapp. Consider sharing components. + +--- + +### Phase 4: Advanced Operator Features (P1) - 1 week +**Goal:** Complete operator management + +1. **Operator Settings** (`/operator/settings`) + - Update ECDSA public key + - Update RPC address + - Set online/offline status + - Unregister from blueprints + +2. **Slashing Dashboard** (`/operator/slashing`) + - View pending slashing proposals + - Dispute button with reason + - Slashing history + +**New Hooks Required:** +- `useUpdateOperatorPreferencesTx` +- `useSetOperatorOnlineTx` +- `useUnregisterOperatorTx` +- `useDisputeSlashTx` +- `useSlashingProposals` - Query slashing proposals + +--- + +### Phase 5: Advanced Service Features (P1-P2) - 1 week +**Goal:** Enable advanced deployment options + +1. **Custom Exposure Deployment** + - Per-operator exposure sliders in deployment wizard + - Exposure validation (can exceed 100% total) + +2. **Multi-Asset Security** + - Asset selection with min/max exposure per asset + - Security requirements builder + +3. **Service Settings** (`/services/:id/settings`) + - Add/remove permitted callers + - Fund subscription escrow + - View billing history + +**New Hooks Required:** +- `useRequestServiceWithExposureTx` +- `useRequestServiceWithSecurityTx` +- `useFundServiceTx` +- `useAddPermittedCallerTx` +- `useRemovePermittedCallerTx` + +--- + +### Phase 6: Dynamic Membership & RFQ (P2) - 1 week +**Goal:** Advanced service features + +1. **Dynamic Service Management** + - Join service as operator + - Leave service (with exit queue) + - View exit schedule + +2. **RFQ Deployment** + - Request quotes from operators (off-chain) + - Submit signed quotes for instant deployment + +**New Hooks Required:** +- `useJoinServiceTx` +- `useLeaveServiceTx` +- `useScheduleExitTx` +- `useExecuteExitTx` +- `useCreateServiceFromQuotesTx` + +--- + +## PRIORITY SUMMARY + +### Must Have (P0) - 6 weeks +- Job submission and results (customers) +- Result submission (operators) +- Rewards claiming (operators, delegators) +- Blueprint creation (developers) +- Deposit/withdraw/delegate (restakers) + +### Should Have (P1) - 3 weeks +- Blueprint management (update, transfer, deactivate) +- Operator settings (preferences, online status, unregister) +- Slashing disputes +- Custom exposure deployment +- Permitted caller management +- Subscription funding + +### Nice to Have (P2) - 2 weeks +- BLS aggregated results +- Dynamic membership (join/leave services) +- RFQ instant deployment +- Batch job submission + +--- + +## SHARED COMPONENTS TO CREATE + +1. **JobSubmissionForm** - Dynamic form based on job schema +2. **JobResultDisplay** - Render job outputs +3. **RewardsCard** - Pending/claimed rewards +4. **SlashingProposalCard** - Slashing details with dispute +5. **BlueprintWizard** - Multi-step blueprint creation +6. **ExposureSlider** - Operator exposure selection +7. **AssetSecurityBuilder** - Multi-asset security requirements +8. **DelegationCard** - Delegation details and actions + +--- + +## NEW GRAPHQL QUERIES NEEDED + +```graphql +# Jobs +query JobsByService($serviceId: ID!) { ... } +query JobsByOperator($operator: Address!) { ... } +query JobResult($callId: ID!) { ... } + +# Rewards +query PendingRewards($account: Address!) { ... } +query RewardsHistory($account: Address!) { ... } + +# Slashing +query SlashingProposals($operator: Address!) { ... } +query SlashingHistory($operator: Address!) { ... } + +# Delegations +query DelegatorInfo($address: Address!) { ... } +query DelegationsByDelegator($delegator: Address!) { ... } +query DelegationsByOperator($operator: Address!) { ... } +``` + +--- + +## ESTIMATED TIMELINE + +| Phase | Duration | Deliverables | +|-------|----------|--------------| +| Phase 1 | 2 weeks | Job flow, rewards | +| Phase 2 | 2 weeks | Blueprint creation | +| Phase 3 | 2 weeks | Restaking/delegation | +| Phase 4 | 1 week | Operator settings | +| Phase 5 | 1 week | Advanced deployment | +| Phase 6 | 1 week | Dynamic membership, RFQ | +| **Total** | **9 weeks** | Full feature parity | + +--- + +## NEXT STEPS + +1. Review this plan and prioritize +2. Create GitHub issues for each phase +3. Start with Phase 1 (Job flow) as it enables end-to-end usage +4. Coordinate with indexer team for new GraphQL queries +5. Coordinate with contract team for ABI updates diff --git a/apps/tangle-cloud/pnpm-lock.yaml b/apps/tangle-cloud/pnpm-lock.yaml new file mode 100644 index 0000000000..9b60ae1782 --- /dev/null +++ b/apps/tangle-cloud/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/apps/tangle-cloud/src/app/app.tsx b/apps/tangle-cloud/src/app/app.tsx index fc264d3b75..021e09ce57 100644 --- a/apps/tangle-cloud/src/app/app.tsx +++ b/apps/tangle-cloud/src/app/app.tsx @@ -5,13 +5,21 @@ import BlueprintsLayout from '../pages/blueprints/layout'; import BlueprintsPage from '../pages/blueprints/page'; import InstancesLayout from '../pages/instances/layout'; import InstancesPage from '../pages/instances/page'; +import ServiceDetailPage from '../pages/services/[id]/page'; import Providers from './providers'; import { PagePath } from '../types'; -import RegistrationReview from '../pages/registrationReview/page'; -import RegistrationLayout from '../pages/registrationReview/layout'; import DeployPage from '../pages/blueprints/[id]/deploy/page'; import OperatorsPage from '../pages/operators/page'; import OperatorsLayout from '../pages/operators/layout'; +import OperatorsManagePage from '../pages/operators/manage/page'; +import OperatorsManageLayout from '../pages/operators/manage/layout'; +import RewardsPage from '../pages/rewards/page'; +import RewardsLayout from '../pages/rewards/layout'; +import EarningsPage from '../pages/earnings/page'; +import EarningsLayout from '../pages/earnings/layout'; +// TODO: Re-enable when blueprint creation UI is properly implemented +// import CreateBlueprintPage from '../pages/blueprints/create/page'; +import ManageBlueprintsPage from '../pages/blueprints/manage/page'; import NotFoundPage from '../pages/notFound'; import { FC } from 'react'; @@ -35,6 +43,15 @@ const App: FC = () => { } /> + + + + } + /> + { } /> + {/* TODO: Re-enable when blueprint creation UI is properly implemented + + + + } + /> + */} + + + + + } + /> + { /> + {/* Redirect old registration review page to blueprints */} - - - } + element={} /> { } /> + + + + } + /> + + + + + } + /> + + + + + } + /> + } /> diff --git a/apps/tangle-cloud/src/app/providers.tsx b/apps/tangle-cloud/src/app/providers.tsx index 2ce3d94f10..20748aca7d 100644 --- a/apps/tangle-cloud/src/app/providers.tsx +++ b/apps/tangle-cloud/src/app/providers.tsx @@ -1,32 +1,43 @@ 'use client'; -import { - AppEvent, - WebbProvider, -} from '@tangle-network/api-provider-environment'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { config } from '@tangle-network/dapp-config/wagmi-config'; import { UIProvider } from '@tangle-network/ui-components'; -import { FC, type PropsWithChildren } from 'react'; -import type { State } from 'wagmi'; +import { + ANVIL_LOCAL_NETWORK, + BASE_SEPOLIA_NETWORK, + BASE_NETWORK, +} from '@tangle-network/ui-components/constants/networks'; +import useNetworkSync from '@tangle-network/tangle-shared-ui/hooks/useNetworkSync'; +import { IndexerStatusProvider } from '@tangle-network/tangle-shared-ui/context/IndexerStatusContext'; +import { FC, type PropsWithChildren, useState } from 'react'; +import { WagmiProvider } from 'wagmi'; -const appEvent = new AppEvent(); +// EVM networks available in tangle-cloud +const TANGLE_CLOUD_NETWORKS = [ + ANVIL_LOCAL_NETWORK, + BASE_SEPOLIA_NETWORK, + BASE_NETWORK, +]; -type Props = { - wagmiInitialState?: State; +// Component to sync network store with wagmi chain +const NetworkSync: FC = ({ children }) => { + useNetworkSync(TANGLE_CLOUD_NETWORKS); + return children; }; -const Providers: FC> = ({ - children, - wagmiInitialState, -}) => { +const Providers: FC = ({ children }) => { + const [queryClient] = useState(() => new QueryClient()); + return ( - - {children} - + + + + {children} + + + ); }; diff --git a/apps/tangle-cloud/src/components/ErrorMessage.tsx b/apps/tangle-cloud/src/components/ErrorMessage.tsx deleted file mode 100644 index 344e6173eb..0000000000 --- a/apps/tangle-cloud/src/components/ErrorMessage.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import isDefined from '@tangle-network/dapp-types/utils/isDefined'; -import { InformationLine } from '@tangle-network/icons'; -import type { PropsOf } from '@tangle-network/ui-components/types'; -import { Typography } from '@tangle-network/ui-components/typography/Typography'; -import { ComponentProps } from 'react'; -import { twMerge } from 'tailwind-merge'; - -type Props = PropsOf<'p'> & { - typographyProps?: Partial>; -}; - -export default function ErrorMessage({ - children, - className, - typographyProps: { - variant = 'body3', - className: typoClassName, - ...typographyProps - } = {}, - ...props -}: Props) { - return ( -

- {isDefined(children) ? ( - - ) : null} - - - {children} - -

- ); -} diff --git a/apps/tangle-cloud/src/components/Header.tsx b/apps/tangle-cloud/src/components/Header.tsx index 829b105518..51d233d5d9 100644 --- a/apps/tangle-cloud/src/components/Header.tsx +++ b/apps/tangle-cloud/src/components/Header.tsx @@ -1,8 +1,21 @@ import NetworkSelectorDropdown from '@tangle-network/tangle-shared-ui/components/NetworkSelectorDropdown'; import ConnectWalletButton from '@tangle-network/tangle-shared-ui/components/ConnectWalletButton'; +import ConnectionStatusButton from '@tangle-network/tangle-shared-ui/components/ConnectionStatusButton'; +import { + ANVIL_LOCAL_NETWORK, + BASE_NETWORK, + BASE_SEPOLIA_NETWORK, +} from '@tangle-network/ui-components/constants/networks'; import { ComponentProps } from 'react'; import { twMerge } from 'tailwind-merge'; +// EVM networks for tangle-cloud (same as in providers.tsx) +const TANGLE_CLOUD_NETWORKS = [ + ANVIL_LOCAL_NETWORK, + BASE_SEPOLIA_NETWORK, + BASE_NETWORK, +]; + export default function Header({ className, ...props @@ -13,7 +26,12 @@ export default function Header({ {...props} >
- + + +
diff --git a/apps/tangle-cloud/src/components/NestedOperatorCell.tsx b/apps/tangle-cloud/src/components/NestedOperatorCell.tsx index a6f9051640..db46f5a2af 100644 --- a/apps/tangle-cloud/src/components/NestedOperatorCell.tsx +++ b/apps/tangle-cloud/src/components/NestedOperatorCell.tsx @@ -9,24 +9,22 @@ import { shortenString, Typography, } from '@tangle-network/ui-components'; -import { Link } from 'react-router'; -import { ExternalLinkLine } from '@tangle-network/icons'; import { Children, FC } from 'react'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { IdentityType } from '@tangle-network/tangle-shared-ui/utils/polkadot/identity'; -import { SubstrateAddress } from '@tangle-network/ui-components/types/address'; +import { Address } from 'viem'; + +type OperatorMetadata = { + name?: string; +}; type NestedOperatorCellProps = { - operators?: SubstrateAddress[]; - operatorIdentityMap?: Map; + operators?: Address[]; + operatorMetadataMap?: Map; }; export const NestedOperatorCell: FC = ({ operators, - operatorIdentityMap, + operatorMetadataMap, }) => { - const network = useNetworkStore((store) => store.network); - if (!operators || !Array.isArray(operators) || operators.length === 0) { return EMPTY_VALUE_PLACEHOLDER; } @@ -44,8 +42,8 @@ export const NestedOperatorCell: FC = ({ .map((operator) => ( )), @@ -56,7 +54,6 @@ export const NestedOperatorCell: FC = ({ {operators.length > 1 && Children.toArray( operators.map((operator) => { - const explorerUrl = network.createExplorerAccountUrl(operator); return (
@@ -64,29 +61,19 @@ export const NestedOperatorCell: FC = ({
{shortenString( - operatorIdentityMap?.get(operator)?.name || - operator.toString(), + operatorMetadataMap?.get(operator)?.name || + operator, )}
- {explorerUrl && ( - - - - )}
); diff --git a/apps/tangle-cloud/src/components/ServiceRequestDetails/BlueprintInfoCard.tsx b/apps/tangle-cloud/src/components/ServiceRequestDetails/BlueprintInfoCard.tsx new file mode 100644 index 0000000000..874a8f3c75 --- /dev/null +++ b/apps/tangle-cloud/src/components/ServiceRequestDetails/BlueprintInfoCard.tsx @@ -0,0 +1,99 @@ +import { FC } from 'react'; +import { Typography } from '@tangle-network/ui-components/typography/Typography'; +import { EMPTY_VALUE_PLACEHOLDER } from '@tangle-network/ui-components/constants'; +import { isEthereumAddress } from '@polkadot/util-crypto'; +import { shortenHex } from '@tangle-network/ui-components/utils/shortenHex'; +import { shortenString } from '@tangle-network/ui-components/utils/shortenString'; +import { isSubstrateAddress } from '@tangle-network/ui-components/utils/isSubstrateAddress'; +import { twMerge } from 'tailwind-merge'; + +type Props = { + name: string; + author: string; + description: string; + instancesCount: number; + operatorsCount: number; +}; + +const BlueprintInfoCard: FC = ({ + name, + author, + description, + instancesCount, + operatorsCount, +}) => { + const formattedAuthor = isEthereumAddress(author) + ? shortenHex(author) + : isSubstrateAddress(author) + ? shortenString(author) + : author; + + return ( +
+
+
+ + {name} + + + + {formattedAuthor} + +
+ + + {description} + + +
+
+ + Instances + + + + {instancesCount ?? EMPTY_VALUE_PLACEHOLDER} + +
+ +
+ + Operators + + + + {operatorsCount ?? EMPTY_VALUE_PLACEHOLDER} + +
+
+
+
+ ); +}; + +export default BlueprintInfoCard; diff --git a/apps/tangle-cloud/src/components/ServiceRequestDetails/CommitmentSection.tsx b/apps/tangle-cloud/src/components/ServiceRequestDetails/CommitmentSection.tsx new file mode 100644 index 0000000000..3d8bd2aa5e --- /dev/null +++ b/apps/tangle-cloud/src/components/ServiceRequestDetails/CommitmentSection.tsx @@ -0,0 +1,102 @@ +import { FC } from 'react'; +import { + Chip, + SkeletonLoader, + Typography, +} from '@tangle-network/ui-components'; +import { + MembershipModel, + getMembershipLabel, + formatTtl, + formatCreatedAt, +} from '../../types/serviceRequest'; + +type Props = { + ttl: bigint | undefined; + createdAt: bigint | undefined; + membership: MembershipModel | undefined; + minOperators: number | undefined; + maxOperators: number | undefined; + totalOperators: number; + isLoading: boolean; +}; + +const CommitmentSection: FC = ({ + ttl, + createdAt, + membership, + minOperators, + maxOperators: _maxOperators, + totalOperators, + isLoading, +}) => { + if (isLoading) { + return ( +
+ + Commitment Details + + +
+ + + +
+
+ ); + } + + const durationText = ttl !== undefined ? formatTtl(ttl) : '-'; + const createdText = + createdAt !== undefined ? formatCreatedAt(createdAt) : '-'; + + // For Fixed: all operators required. For Dynamic: use minOperators. + const minApprovalsRequired = + membership === MembershipModel.Fixed + ? totalOperators + : (minOperators ?? totalOperators); + + return ( +
+ + Commitment Details + + +
+
+ + Duration: + + {durationText} +
+ +
+ + Created: + + {createdText} +
+ +
+ + Membership: + + + {membership !== undefined ? getMembershipLabel(membership) : '-'} + +
+ +
+ + Min. Approvals Required: + + {minApprovalsRequired} +
+
+
+ ); +}; + +export default CommitmentSection; diff --git a/apps/tangle-cloud/src/components/ServiceRequestDetails/OperatorStatusSection.tsx b/apps/tangle-cloud/src/components/ServiceRequestDetails/OperatorStatusSection.tsx new file mode 100644 index 0000000000..5113a7ae8b --- /dev/null +++ b/apps/tangle-cloud/src/components/ServiceRequestDetails/OperatorStatusSection.tsx @@ -0,0 +1,157 @@ +import { FC, useMemo } from 'react'; +import { Address } from 'viem'; +import { + Avatar, + Chip, + SkeletonLoader, + Typography, +} from '@tangle-network/ui-components'; +import { shortenString } from '@tangle-network/ui-components/utils/shortenString'; +import { + OperatorApprovalStatus, + OperatorWithStatus, +} from '../../types/serviceRequest'; + +type Props = { + operatorCandidates: Address[]; + approvedOperators: Address[]; + rejectedOperators: Address[]; + approvalCount: number; + currentOperator: Address | undefined; + isLoading: boolean; +}; + +const getStatusColor = (status: OperatorApprovalStatus) => { + switch (status) { + case 'approved': + return 'green'; + case 'rejected': + return 'red'; + case 'pending': + default: + return 'yellow'; + } +}; + +const getStatusLabel = (status: OperatorApprovalStatus) => { + switch (status) { + case 'approved': + return 'Approved'; + case 'rejected': + return 'Rejected'; + case 'pending': + default: + return 'Pending'; + } +}; + +const OperatorStatusSection: FC = ({ + operatorCandidates, + approvedOperators, + rejectedOperators, + approvalCount, + currentOperator, + isLoading, +}) => { + const operatorsWithStatus = useMemo((): OperatorWithStatus[] => { + const approvedSet = new Set( + approvedOperators.map((addr) => addr.toLowerCase()), + ); + const rejectedSet = new Set( + rejectedOperators.map((addr) => addr.toLowerCase()), + ); + + return operatorCandidates.map((address) => { + const addrLower = address.toLowerCase(); + let status: OperatorApprovalStatus = 'pending'; + + if (approvedSet.has(addrLower)) { + status = 'approved'; + } else if (rejectedSet.has(addrLower)) { + status = 'rejected'; + } + + return { address, status }; + }); + }, [operatorCandidates, approvedOperators, rejectedOperators]); + + if (isLoading) { + return ( +
+ + Operator Status + + + + +
+ + +
+
+ ); + } + + const totalOperators = operatorCandidates.length; + + return ( +
+ + Operator Status + + +
+ + Progress: + + + + {approvalCount}/{totalOperators} operators approved + +
+ +
+ {operatorsWithStatus.map(({ address, status }) => { + const isCurrentOperator = + currentOperator?.toLowerCase() === address.toLowerCase(); + + return ( +
+
+ + + + {shortenString(address, 6)} + + + {isCurrentOperator && ( + + (You) + + )} +
+ + + {getStatusLabel(status)} + +
+ ); + })} +
+
+ ); +}; + +export default OperatorStatusSection; diff --git a/apps/tangle-cloud/src/components/ServiceRequestDetails/PaymentTermsSection.tsx b/apps/tangle-cloud/src/components/ServiceRequestDetails/PaymentTermsSection.tsx new file mode 100644 index 0000000000..72bf7d4a47 --- /dev/null +++ b/apps/tangle-cloud/src/components/ServiceRequestDetails/PaymentTermsSection.tsx @@ -0,0 +1,72 @@ +import { FC } from 'react'; +import { Address, formatUnits, zeroAddress } from 'viem'; +import { SkeletonLoader, Typography } from '@tangle-network/ui-components'; +import addCommasToNumber from '@tangle-network/ui-components/utils/addCommasToNumber'; + +type Props = { + paymentToken: Address | undefined; + paymentAmount: bigint | undefined; + tokenSymbol: string; + tokenDecimals: number; + isLoading: boolean; +}; + +const PaymentTermsSection: FC = ({ + paymentToken, + paymentAmount, + tokenSymbol, + tokenDecimals, + isLoading, +}) => { + if (isLoading) { + return ( +
+ + Payment Terms + + +
+ + +
+
+ ); + } + + const isNativePayment = !paymentToken || paymentToken === zeroAddress; + const formattedAmount = paymentAmount + ? addCommasToNumber( + parseFloat(formatUnits(paymentAmount, tokenDecimals)).toFixed(4), + ) + : '0'; + + return ( +
+ + Payment Terms + + +
+
+ + Payment Token: + + + {isNativePayment ? 'Native (ETH)' : tokenSymbol} + +
+ +
+ + Payment Amount: + + + {formattedAmount} {tokenSymbol} + +
+
+
+ ); +}; + +export default PaymentTermsSection; diff --git a/apps/tangle-cloud/src/components/ServiceRequestDetails/SecurityRequirementsSection.tsx b/apps/tangle-cloud/src/components/ServiceRequestDetails/SecurityRequirementsSection.tsx new file mode 100644 index 0000000000..027934ef5a --- /dev/null +++ b/apps/tangle-cloud/src/components/ServiceRequestDetails/SecurityRequirementsSection.tsx @@ -0,0 +1,94 @@ +import { FC, useMemo } from 'react'; +import { zeroAddress } from 'viem'; +import { SkeletonLoader, Typography } from '@tangle-network/ui-components'; +import type { AssetSecurityRequirement } from '@tangle-network/tangle-shared-ui/data/services'; +import { useEvmAssetMetadatas } from '@tangle-network/tangle-shared-ui/hooks/useEvmAssetMetadatas'; +import type { EvmAddress } from '@tangle-network/ui-components/types/address'; + +type Props = { + securityRequirements: AssetSecurityRequirement[]; + isLoading: boolean; +}; + +const formatBps = (bps: number): string => { + return `${(bps / 100).toFixed(2)}%`; +}; + +const SecurityRequirementsSection: FC = ({ + securityRequirements, + isLoading, +}) => { + // Extract token addresses for metadata resolution + const tokenAddresses = useMemo(() => { + if (securityRequirements.length === 0) { + return null; + } + return securityRequirements.map((req) => { + // For native token (kind=0), use zero address for metadata lookup + // For ERC20 (kind=1), use the token address + const addr = req.asset.kind === 0 ? zeroAddress : req.asset.token; + return addr as EvmAddress; + }); + }, [securityRequirements]); + + // Fetch token metadata + const { data: tokenMetadatas, isLoading: isLoadingMetadata } = + useEvmAssetMetadatas(tokenAddresses); + + // Get asset label from metadata or fallback + const getAssetLabel = (index: number): string => { + const metadata = tokenMetadatas?.[index]; + if (metadata?.symbol) { + return metadata.symbol; + } + // Fallback: check if native (kind=0) + const req = securityRequirements[index]; + if (req.asset.kind === 0 || req.asset.token === zeroAddress) { + return 'ETH'; + } + return `${req.asset.token.slice(0, 6)}...${req.asset.token.slice(-4)}`; + }; + + if (isLoading || isLoadingMetadata) { + return ( +
+ + Security Requirements + + + +
+ ); + } + + if (securityRequirements.length === 0) { + return null; + } + + return ( +
+ + Security Requirements + + +
+ {securityRequirements.map((req, index) => ( +
+ + {getAssetLabel(index)} + + + + {formatBps(req.minExposureBps)} - {formatBps(req.maxExposureBps)} + +
+ ))} +
+
+ ); +}; + +export default SecurityRequirementsSection; diff --git a/apps/tangle-cloud/src/components/ServiceRequestDetails/ServiceRequestSummary.tsx b/apps/tangle-cloud/src/components/ServiceRequestDetails/ServiceRequestSummary.tsx new file mode 100644 index 0000000000..7c07043a5e --- /dev/null +++ b/apps/tangle-cloud/src/components/ServiceRequestDetails/ServiceRequestSummary.tsx @@ -0,0 +1,85 @@ +import { FC } from 'react'; +import { Address } from 'viem'; +import { Divider } from '@tangle-network/ui-components'; +import type { + ServiceRequestContractDetails, + AssetSecurityRequirement, +} from '@tangle-network/tangle-shared-ui/data/services'; +import PaymentTermsSection from './PaymentTermsSection'; +import CommitmentSection from './CommitmentSection'; +import OperatorStatusSection from './OperatorStatusSection'; +import SecurityRequirementsSection from './SecurityRequirementsSection'; + +type Props = { + contractDetails: ServiceRequestContractDetails | null | undefined; + tokenSymbol: string; + tokenDecimals: number; + operatorCandidates: Address[]; + approvedOperators: Address[]; + rejectedOperators: Address[]; + currentOperator: Address | undefined; + isLoading: boolean; +}; + +const ServiceRequestSummary: FC = ({ + contractDetails, + tokenSymbol, + tokenDecimals, + operatorCandidates, + approvedOperators, + rejectedOperators, + currentOperator, + isLoading, +}) => { + const securityRequirements: AssetSecurityRequirement[] = + contractDetails?.securityRequirements ?? []; + const hasSecurityRequirements = securityRequirements.length > 0; + + return ( +
+ + + + + + + + + + + {hasSecurityRequirements && ( + <> + + + + + )} +
+ ); +}; + +export default ServiceRequestSummary; diff --git a/apps/tangle-cloud/src/components/ServiceRequestDetails/index.ts b/apps/tangle-cloud/src/components/ServiceRequestDetails/index.ts new file mode 100644 index 0000000000..15da97ff03 --- /dev/null +++ b/apps/tangle-cloud/src/components/ServiceRequestDetails/index.ts @@ -0,0 +1,6 @@ +export { default as ServiceRequestSummary } from './ServiceRequestSummary'; +export { default as PaymentTermsSection } from './PaymentTermsSection'; +export { default as CommitmentSection } from './CommitmentSection'; +export { default as OperatorStatusSection } from './OperatorStatusSection'; +export { default as SecurityRequirementsSection } from './SecurityRequirementsSection'; +export { default as BlueprintInfoCard } from './BlueprintInfoCard'; diff --git a/apps/tangle-cloud/src/components/Sidebar.tsx b/apps/tangle-cloud/src/components/Sidebar.tsx index dbf43ac420..64956f7825 100644 --- a/apps/tangle-cloud/src/components/Sidebar.tsx +++ b/apps/tangle-cloud/src/components/Sidebar.tsx @@ -19,7 +19,11 @@ import { import { FC } from 'react'; import { useLocation } from 'react-router'; import { PagePath } from '../types'; -import { HomeFillIcon } from '@tangle-network/icons'; +import { + HomeFillIcon, + GiftLineIcon, + CoinsLineIcon, +} from '@tangle-network/icons'; type Props = { isExpandedByDefault?: boolean; @@ -47,6 +51,20 @@ const SIDEBAR_ITEMS: SideBarItemProps[] = [ Icon: GlobalLine, subItems: [], }, + { + name: 'Rewards', + href: PagePath.REWARDS, + isInternal: true, + Icon: GiftLineIcon, + subItems: [], + }, + { + name: 'Earnings', + href: PagePath.EARNINGS, + isInternal: true, + Icon: CoinsLineIcon, + subItems: [], + }, // External links { diff --git a/apps/tangle-cloud/src/components/tangleCloudTable/TangleCloudTable.tsx b/apps/tangle-cloud/src/components/tangleCloudTable/TangleCloudTable.tsx index 920e1affd9..ec2adef907 100644 --- a/apps/tangle-cloud/src/components/tangleCloudTable/TangleCloudTable.tsx +++ b/apps/tangle-cloud/src/components/tangleCloudTable/TangleCloudTable.tsx @@ -1,60 +1,133 @@ -import { Table } from '@tangle-network/ui-components'; +import { Table, Typography } from '@tangle-network/ui-components'; import TableStatus from '@tangle-network/tangle-shared-ui/components/tables/TableStatus'; import { TableVariant } from '@tangle-network/ui-components/components/Table/types'; import { twMerge } from 'tailwind-merge'; import { TangleCloudTableProps } from './type'; import { RowData } from '@tanstack/react-table'; +const GLASS_CONTAINER_CLASS = + 'w-full px-4 py-4 rounded-2xl overflow-hidden border border-mono-0 dark:border-mono-160 bg-[linear-gradient(180deg,rgba(255,255,255,0.20)0%,rgba(255,255,255,0.00)100%)] dark:bg-[linear-gradient(180deg,rgba(43,47,64,0.20)0%,rgba(43,47,64,0.00)100%)]'; + export const TangleCloudTable = ({ title, + hideTitle = false, data, isLoading, + error, loadingTableProps, + errorTableProps, emptyTableProps, + onRetry, tableConfig, tableProps, }: TangleCloudTableProps) => { const isEmpty = data.length === 0; + const hasRetry = typeof onRetry === 'function'; + const errorMessage = error?.message?.trim(); + const diagnostics = errorMessage + ? errorMessage.length > 140 + ? `${errorMessage.slice(0, 137)}...` + : errorMessage + : 'Indexer or network request failed.'; + const hasTitle = !hideTitle; + + const titleElement = hasTitle ? ( + + {title} + + ) : undefined; if (isLoading) { return ( - +
+ {hasTitle ? titleElement : null} + + +
); - } else if (isEmpty) { + } + + if (error) { return ( - +
+ {hasTitle ? titleElement : null} + + void onRetry() } : undefined) + } + className={twMerge( + 'w-full !border-0 !rounded-none !p-0', + hasTitle ? 'mt-3' : null, + errorTableProps?.className, + )} + /> +
+ ); + } + + if (isEmpty) { + return ( +
+ {hasTitle ? titleElement : null} + + +
); } return ( ); }; diff --git a/apps/tangle-cloud/src/components/tangleCloudTable/type.ts b/apps/tangle-cloud/src/components/tangleCloudTable/type.ts index 5cbaa46782..1efad71b5c 100644 --- a/apps/tangle-cloud/src/components/tangleCloudTable/type.ts +++ b/apps/tangle-cloud/src/components/tangleCloudTable/type.ts @@ -5,11 +5,14 @@ import { RowData, type useReactTable } from '@tanstack/react-table'; export interface TangleCloudTableProps { title: string; + hideTitle?: boolean; data: T[]; isLoading: boolean; error: Error | null; loadingTableProps?: Partial; + errorTableProps?: Partial; emptyTableProps?: Partial; + onRetry?: () => void | Promise; tableConfig?: Partial>>; tableProps: ReturnType>; } diff --git a/apps/tangle-cloud/src/constants/cloudInstruction.tsx b/apps/tangle-cloud/src/constants/cloudInstruction.tsx index 1c819b9c83..d8a729062a 100644 --- a/apps/tangle-cloud/src/constants/cloudInstruction.tsx +++ b/apps/tangle-cloud/src/constants/cloudInstruction.tsx @@ -2,32 +2,26 @@ import { CloudOutlineIcon, GlobalLine } from '@tangle-network/icons'; import { GridFillIcon } from '@tangle-network/icons/GridFillIcon'; import { PagePath } from '../types'; -const ICON_CLASSNAME = 'h-6 w-6 fill-mono-120 !dark:fill-mono-0'; - export const CLOUD_INSTRUCTIONS = [ { - title: 'Getting started with Tangle Cloud', - description: 'Learn how to set up and manage decentralized services.', - icon: CloudOutlineIcon, - to: 'https://docs.tangle.tools/developers/blueprints/introduction', - className: ICON_CLASSNAME, - external: true, - }, - { - title: 'Register as an Operator', - description: 'Register as an Operator to participate in managing services.', + title: 'Become an Operator', + description: 'Start earning by running decentralized services on Tangle.', icon: GlobalLine, to: PagePath.OPERATORS, - className: ICON_CLASSNAME, external: false, }, { - title: 'Register and run Blueprints', - description: - 'Browse available Blueprints to select services you can operate and support.', + title: 'Browse Blueprints', + description: 'Discover and deploy available service blueprints.', icon: GridFillIcon, to: PagePath.BLUEPRINTS, - className: ICON_CLASSNAME, external: false, }, + { + title: 'Read the Docs', + description: 'Learn how to build and operate services on Tangle.', + icon: CloudOutlineIcon, + to: 'https://docs.tangle.tools/developers/blueprints/introduction', + external: true, + }, ]; diff --git a/apps/tangle-cloud/src/constants/index.ts b/apps/tangle-cloud/src/constants/index.ts index 7ea9f68732..5e8c24104e 100644 --- a/apps/tangle-cloud/src/constants/index.ts +++ b/apps/tangle-cloud/src/constants/index.ts @@ -1,11 +1,13 @@ +/** + * Transaction names used in tangle-cloud for notifications. + * These are displayed to users in processing/success/error notifications. + */ export enum TxName { REGISTER_BLUEPRINT = 'register blueprint', + UNREGISTER_BLUEPRINT = 'unregister blueprint', REJECT_SERVICE_REQUEST = 'reject service request', APPROVE_SERVICE_REQUEST = 'approve service request', DEPLOY_BLUEPRINT = 'deploy blueprint', TERMINATE_SERVICE_INSTANCE = 'terminate service instance', -} - -export enum SessionStorageKey { - BLUEPRINT_REGISTRATION_PARAMS = 'blueprintRegistrationParams', + CLAIM_EARNINGS = 'claim earnings', } diff --git a/apps/tangle-cloud/src/data/operators/useOperatorStats.ts b/apps/tangle-cloud/src/data/operators/useOperatorStats.ts new file mode 100644 index 0000000000..be283219e0 --- /dev/null +++ b/apps/tangle-cloud/src/data/operators/useOperatorStats.ts @@ -0,0 +1,61 @@ +/** + * EVM hook for fetching operator statistics from the Envio indexer. + */ + +import { useMemo } from 'react'; +import { Address } from 'viem'; +import { + useOperator, + useOperatorStats as useOperatorStatsQuery, +} from '@tangle-network/tangle-shared-ui/data/graphql'; + +export interface OperatorStats { + registeredBlueprints: number; + runningServices: number; + pendingServices: number; + avgUptime: number | null; + deployedServices: number; + publishedBlueprints: number; +} + +/** + * Hook to fetch operator statistics for an EVM address. + */ +export const useOperatorStats = ( + operatorAddress: Address | undefined, + _refreshTrigger?: number, +) => { + // Fetch operator data from indexer + const { data: operator, isLoading: isLoadingOperator } = + useOperator(operatorAddress); + + // Fetch operator stats from indexer + const { + data: stats, + isLoading: isLoadingStats, + refetch, + } = useOperatorStatsQuery(operatorAddress); + + const result = useMemo(() => { + if (!operator) { + return null; + } + + return { + registeredBlueprints: stats?.registeredBlueprints ?? 0, + runningServices: stats?.runningServices ?? 0, + pendingServices: stats?.pendingServices ?? 0, + avgUptime: stats?.avgUptime ?? null, + deployedServices: stats?.deployedServices ?? 0, + publishedBlueprints: stats?.publishedBlueprints ?? 0, + }; + }, [operator, stats]); + + return { + result, + isLoading: isLoadingOperator || isLoadingStats, + refetch, + }; +}; + +export default useOperatorStats; diff --git a/apps/tangle-cloud/src/data/operators/useOperatorStatsData.ts b/apps/tangle-cloud/src/data/operators/useOperatorStatsData.ts deleted file mode 100644 index bcc5a4a1b8..0000000000 --- a/apps/tangle-cloud/src/data/operators/useOperatorStatsData.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { toPrimitiveServiceRequest } from '@tangle-network/tangle-shared-ui/data/blueprints/utils/toPrimitiveService'; -import useApiRx from '@tangle-network/tangle-shared-ui/hooks/useApiRx'; -import { SubstrateAddress } from '@tangle-network/ui-components/types/address'; -import { useCallback, useMemo } from 'react'; -import { catchError, combineLatest, map, of } from 'rxjs'; -import { z } from 'zod'; -import { StorageKey, u64 } from '@polkadot/types'; -import { toSubstrateAddress } from '@tangle-network/ui-components/utils/toSubstrateAddress'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { Option } from '@polkadot/types'; -import { - TanglePrimitivesServicesService, - TanglePrimitivesServicesServiceServiceBlueprint, - TanglePrimitivesServicesServiceServiceRequest, - TanglePrimitivesServicesTypesOperatorProfile, -} from '@polkadot/types/lookup'; -import { ITuple } from '@polkadot/types/types'; -import { AccountId32 } from '@polkadot/types/interfaces'; - -const operatorStatsSchema = z.object({ - registeredBlueprints: z.number().default(0), - runningServices: z.number().default(0), - // TODO: Implement this - avgUptime: z.number().default(0), - deployedServices: z.number().default(0), - publishedBlueprints: z.number().default(0), - pendingServices: z.number().default(0), -}); - -export const useOperatorStatsData = ( - operatorAddress: SubstrateAddress | null | undefined, - refreshTrigger?: number, -) => { - const { network } = useNetworkStore(); - - const { result: operatorStats, ...rest } = useApiRx( - useCallback( - (apiRx) => { - if (!operatorAddress) { - return of({}); - } - - const operatorProfile$ = - apiRx.query.services?.operatorsProfile === undefined - ? of({}) - : apiRx.query.services?.operatorsProfile(operatorAddress).pipe( - map((operatorProfile) => { - const unwrapped = ( - operatorProfile as Option - ).unwrapOr(null); - - if (unwrapped === null) { - return {}; - } - - return { - registeredBlueprints: unwrapped.blueprints.size, - runningServices: unwrapped.services.size, - }; - }), - catchError((error) => { - console.error( - 'Error querying services with blueprints by operator profile:', - error, - ); - return of({}); - }), - ); - - const serviceRequest$ = - apiRx.query?.services?.serviceRequests === undefined - ? of({}) - : apiRx.query.services?.serviceRequests.entries().pipe( - map((serviceRequests) => { - const pendingServices = serviceRequests.filter( - ([requestId, serviceRequest]) => { - const unwrapped = ( - serviceRequest as Option - ).unwrapOr(null); - - if (unwrapped === null) { - return false; - } - - const primitiveServiceRequest = toPrimitiveServiceRequest( - requestId as StorageKey<[u64]>, - unwrapped, - ); - return primitiveServiceRequest.operatorsWithApprovalState.some( - (operator) => { - const normalizedChainOperator = toSubstrateAddress( - operator.operator, - network.ss58Prefix, - ); - const normalizedCurrentOperator = operatorAddress - ? toSubstrateAddress( - operatorAddress, - network.ss58Prefix, - ) - : null; - - const addressMatch = - normalizedChainOperator === - normalizedCurrentOperator; - const statusMatch = - operator.approvalStateStatus === 'Pending'; - - return addressMatch && statusMatch; - }, - ); - }, - ); - return { - pendingServices: pendingServices.length, - }; - }), - catchError((error) => { - console.error( - 'Error querying services with blueprints by operator profile:', - error, - ); - return of({}); - }), - ); - - const publishedBlueprints$ = - apiRx.query.services?.blueprints === undefined - ? of({}) - : apiRx.query.services?.blueprints?.entries().pipe( - map((blueprints) => { - const publishedBlueprints = blueprints.filter( - ([, optBlueprint]) => { - const unwrapped = ( - optBlueprint as Option< - ITuple< - [ - AccountId32, - TanglePrimitivesServicesServiceServiceBlueprint, - ] - > - > - ).unwrapOr(null); - - if (unwrapped === null) { - return false; - } - - const owner = unwrapped[0]; - const publisher = owner.toHuman(); - return publisher === operatorAddress; - }, - ); - return { - publishedBlueprints: publishedBlueprints.length, - }; - }), - catchError((error) => { - console.error( - 'Error querying services with blueprints:', - error, - ); - return of({}); - }), - ); - - const deployedServices$ = - apiRx.query.services?.instances === undefined - ? of({}) - : apiRx.query.services?.instances.entries().pipe( - map((instances) => { - const deployedServices = instances.filter(([_, instance]) => { - const unwrapped = ( - instance as Option - ).unwrapOr(null); - if (unwrapped === null) { - return false; - } - return unwrapped.owner.toHuman() === operatorAddress; - }); - return { - deployedServices: deployedServices.length, - }; - }), - catchError((error) => { - console.error( - 'Error querying services with blueprints:', - error, - ); - return of({}); - }), - ); - - return combineLatest([ - operatorProfile$, - serviceRequest$, - publishedBlueprints$, - deployedServices$, - ]).pipe( - map( - ([ - operatorProfile, - serviceRequest, - publishedBlueprints, - deployedServices, - ]) => { - return { - ...operatorProfile, - ...serviceRequest, - ...publishedBlueprints, - ...deployedServices, - }; - }, - ), - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [operatorAddress, network.ss58Prefix, refreshTrigger], - ), - ); - - const result = useMemo(() => { - const parsed = operatorStatsSchema.safeParse(operatorStats); - return parsed.success ? parsed.data : null; - }, [operatorStats]); - - return { - result, - ...rest, - }; -}; diff --git a/apps/tangle-cloud/src/data/operators/useUserStats.ts b/apps/tangle-cloud/src/data/operators/useUserStats.ts new file mode 100644 index 0000000000..69faaeb52d --- /dev/null +++ b/apps/tangle-cloud/src/data/operators/useUserStats.ts @@ -0,0 +1,78 @@ +/** + * EVM hook for fetching user statistics from the Envio indexer. + */ + +import { useMemo } from 'react'; +import { Address } from 'viem'; +import { + useServicesByOwner, + usePendingServiceRequests, + useServiceRequestsByRequester, +} from '@tangle-network/tangle-shared-ui/data/graphql'; + +export interface UserStats { + runningServices: number; + deployedServices: number; + pendingServices: number; + consumedServices: number; +} + +/** + * Hook to fetch user statistics for an EVM address. + */ +export const useUserStats = ( + userAddress: Address | undefined, + _refreshTrigger?: number, +) => { + // Fetch services owned by user + const { + data: ownedServices, + isLoading: isLoadingOwned, + refetch: refetchOwned, + } = useServicesByOwner(userAddress); + + // Fetch pending service requests by user + const { + data: pendingRequests, + isLoading: isLoadingPending, + refetch: refetchPending, + } = usePendingServiceRequests(userAddress); + + // Fetch activated requests initiated by the user. + const { + data: activatedRequests, + isLoading: isLoadingActivatedRequests, + refetch: refetchActivatedRequests, + } = useServiceRequestsByRequester(userAddress, { status: 'ACTIVATED' }); + + const result = useMemo(() => { + const activeServices = + ownedServices?.filter((s) => s.status === 'ACTIVE') ?? []; + const allDeployed = ownedServices ?? []; + const pendingCount = pendingRequests?.length ?? 0; + const consumedCount = activatedRequests?.length ?? 0; + + return { + runningServices: activeServices.length, + deployedServices: allDeployed.length, + pendingServices: pendingCount, + consumedServices: consumedCount, + }; + }, [ownedServices, pendingRequests, activatedRequests]); + + const refetch = async () => { + await Promise.all([ + refetchOwned(), + refetchPending(), + refetchActivatedRequests(), + ]); + }; + + return { + result, + isLoading: isLoadingOwned || isLoadingPending || isLoadingActivatedRequests, + refetch, + }; +}; + +export default useUserStats; diff --git a/apps/tangle-cloud/src/data/operators/useUserStatsData.ts b/apps/tangle-cloud/src/data/operators/useUserStatsData.ts deleted file mode 100644 index 4bb80e4291..0000000000 --- a/apps/tangle-cloud/src/data/operators/useUserStatsData.ts +++ /dev/null @@ -1,250 +0,0 @@ -import useApiRx from '@tangle-network/tangle-shared-ui/hooks/useApiRx'; -import { useCallback, useMemo } from 'react'; -import { catchError, combineLatest, map, of } from 'rxjs'; -import { z } from 'zod'; -import { encodeAddress, decodeAddress } from '@polkadot/util-crypto'; -import { Option } from '@polkadot/types'; -import { - TanglePrimitivesServicesService, - TanglePrimitivesServicesServiceServiceRequest, -} from '@polkadot/types/lookup'; - -const userStatsSchema = z.object({ - runningServices: z.number().default(0), - deployedServices: z.number().default(0), - pendingServices: z.number().default(0), - consumedServices: z.number().default(0), -}); - -export const useUserStatsData = ( - accountAddress: string | null | undefined, - refreshTrigger?: number, -) => { - const { result: userStats, ...rest } = useApiRx( - useCallback( - (apiRx) => { - if (!accountAddress) { - return of({}); - } - - const runningServices$ = - apiRx.query.services?.instances === undefined - ? of({}) - : apiRx.query.services?.instances - .entries>() - .pipe( - map((instances) => { - const runningServices = instances.filter( - ([_, instance]) => { - if (instance.isNone) { - return false; - } - const detailed = instance.unwrap(); - const ownerAddress = detailed.owner.toString(); - - try { - const normalizedOwner = encodeAddress( - decodeAddress(ownerAddress), - ); - const normalizedUser = accountAddress - ? encodeAddress(decodeAddress(accountAddress)) - : null; - - return normalizedOwner === normalizedUser; - } catch (error) { - console.error( - 'Address normalization error in useUserStatsData:', - error, - ); - return ownerAddress === accountAddress; - } - }, - ); - return { - runningServices: runningServices.length, - }; - }), - catchError((error) => { - console.error( - 'Error querying services with blueprints:', - error, - ); - return of({}); - }), - ); - - // TODO: after the instance is terminated, this will be removed. using Graphql to get the deployed services - const deployedServices$ = - apiRx.query.services?.instances === undefined - ? of({}) - : apiRx.query.services?.instances - .entries>() - .pipe( - map((instances) => { - const deployedServices = instances.filter( - ([_, instance]) => { - if (instance.isNone) { - return false; - } - const detailed = instance.unwrap(); - const ownerAddress = detailed.owner.toString(); - - try { - const normalizedOwner = encodeAddress( - decodeAddress(ownerAddress), - ); - const normalizedUser = accountAddress - ? encodeAddress(decodeAddress(accountAddress)) - : null; - return normalizedOwner === normalizedUser; - } catch (error) { - console.error( - 'Address normalization error in useUserStatsData:', - error, - ); - return ownerAddress === accountAddress; - } - }, - ); - return { - deployedServices: deployedServices.length, - }; - }), - catchError((error) => { - console.error( - 'Error querying services with blueprints:', - error, - ); - return of({}); - }), - ); - - const pendingServices$ = - apiRx.query.services?.serviceRequests === undefined - ? of({}) - : apiRx.query.services?.serviceRequests - .entries< - Option - >() - .pipe( - map((serviceRequests) => { - const pendingServices = serviceRequests.filter( - ([_, serviceRequest]) => { - if (serviceRequest.isNone) { - return false; - } - const detailed = serviceRequest.unwrap(); - const ownerAddress = detailed.owner.toString(); - - try { - const normalizedOwner = encodeAddress( - decodeAddress(ownerAddress), - ); - const normalizedUser = accountAddress - ? encodeAddress(decodeAddress(accountAddress)) - : null; - return normalizedOwner === normalizedUser; - } catch (error) { - console.error( - 'Address normalization error in useUserStatsData:', - error, - ); - return ownerAddress === accountAddress; - } - }, - ); - return { - pendingServices: pendingServices.length, - }; - }), - catchError((error) => { - console.error( - 'Error querying services with blueprints:', - error, - ); - return of({}); - }), - ); - - const consumedServices$ = - apiRx.query.services?.instances === undefined - ? of({}) - : apiRx.query.services?.instances - .entries>() - .pipe( - map((instances) => { - const consumedServices = instances.filter( - ([_, instance]) => { - if (instance.isNone) { - return false; - } - const detailed = instance.unwrap(); - return detailed.permittedCallers.some( - (caller) => caller.toHuman() === accountAddress, - ); - }, - ); - return { - consumedServices: consumedServices.length, - }; - }), - catchError((error) => { - console.error( - 'Error querying services with blueprints:', - error, - ); - return of({}); - }), - ); - - return combineLatest([ - runningServices$, - deployedServices$, - pendingServices$, - consumedServices$, - ]).pipe( - map( - ([ - runningServices, - deployedServices, - pendingServices, - consumedServices, - ]) => { - return { - ...runningServices, - ...deployedServices, - ...pendingServices, - ...consumedServices, - }; - }, - ), - catchError((error) => { - console.error('Error querying services with blueprints:', error); - return of({}); - }), - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [accountAddress, refreshTrigger], - ), - ); - - const result = useMemo(() => { - const parsed = userStatsSchema.safeParse(userStats); - if (!parsed.success) { - console.error(parsed.error); - return { - runningServices: 0, - deployedServices: 0, - pendingServices: 0, - consumedServices: 0, - }; - } - return parsed.data; - }, [userStats]); - - return { - result, - ...rest, - }; -}; diff --git a/apps/tangle-cloud/src/data/services/useCancelExitTx.ts b/apps/tangle-cloud/src/data/services/useCancelExitTx.ts new file mode 100644 index 0000000000..732727a353 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useCancelExitTx.ts @@ -0,0 +1,64 @@ +/** + * EVM hook for canceling a scheduled exit from a service via the Tangle contract. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; + +export { TxStatus }; + +export interface CancelExitParams { + serviceId: bigint; +} + +export interface UseCancelExitTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export const useCancelExitTx = (options?: UseCancelExitTxOptions) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TANGLE_ABI, + (params: CancelExitParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'cancelExit' as const, + args: [params.serviceId] as const, + }), + { + txName: 'cancel exit', + txDetails: (params) => + new Map([['Service ID', params.serviceId.toString()]]), + getSuccessMessage: (params) => + `Successfully canceled exit from service #${params.serviceId}`, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exitStatus'] }); + queryClient.invalidateQueries({ queryKey: ['exitRequest'] }); + queryClient.invalidateQueries({ queryKey: ['canScheduleExit'] }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useCancelExitTx; diff --git a/apps/tangle-cloud/src/data/services/useExecuteExitTx.ts b/apps/tangle-cloud/src/data/services/useExecuteExitTx.ts new file mode 100644 index 0000000000..3f553b3c92 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useExecuteExitTx.ts @@ -0,0 +1,65 @@ +/** + * EVM hook for executing a scheduled exit from a service via the Tangle contract. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; + +export { TxStatus }; + +export interface ExecuteExitParams { + serviceId: bigint; +} + +export interface UseExecuteExitTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export const useExecuteExitTx = (options?: UseExecuteExitTxOptions) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TANGLE_ABI, + (params: ExecuteExitParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'executeExit' as const, + args: [params.serviceId] as const, + }), + { + txName: 'execute exit', + txDetails: (params) => + new Map([['Service ID', params.serviceId.toString()]]), + getSuccessMessage: (params) => + `Successfully exited service #${params.serviceId}`, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exitStatus'] }); + queryClient.invalidateQueries({ queryKey: ['exitRequest'] }); + queryClient.invalidateQueries({ queryKey: ['serviceOperators'] }); + queryClient.invalidateQueries({ queryKey: ['serviceDetails'] }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useExecuteExitTx; diff --git a/apps/tangle-cloud/src/data/services/useForceExitTx.ts b/apps/tangle-cloud/src/data/services/useForceExitTx.ts new file mode 100644 index 0000000000..b9dd19528f --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useForceExitTx.ts @@ -0,0 +1,71 @@ +/** + * EVM hook for force-exiting an operator from a service via the Tangle contract. + * Only the service owner can use this when forceExitAllowed is true. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; +import { Address } from 'viem'; + +export { TxStatus }; + +export interface ForceExitParams { + serviceId: bigint; + operator: Address; +} + +export interface UseForceExitTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export const useForceExitTx = (options?: UseForceExitTxOptions) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TANGLE_ABI, + (params: ForceExitParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'forceExit' as const, + args: [params.serviceId, params.operator] as const, + }), + { + txName: 'force exit operator', + txDetails: (params) => + new Map([ + ['Service ID', params.serviceId.toString()], + ['Operator', params.operator], + ]), + getSuccessMessage: (params) => + `Successfully force-exited operator from service #${params.serviceId}`, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exitStatus'] }); + queryClient.invalidateQueries({ queryKey: ['exitRequest'] }); + queryClient.invalidateQueries({ queryKey: ['serviceOperators'] }); + queryClient.invalidateQueries({ queryKey: ['serviceDetails'] }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useForceExitTx; diff --git a/apps/tangle-cloud/src/data/services/useJoinServiceTx.ts b/apps/tangle-cloud/src/data/services/useJoinServiceTx.ts new file mode 100644 index 0000000000..a3f0c6881a --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useJoinServiceTx.ts @@ -0,0 +1,68 @@ +/** + * EVM hook for joining a service as an operator via the Tangle contract. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; + +export { TxStatus }; + +export interface JoinServiceParams { + serviceId: bigint; + exposureBps: number; +} + +export interface UseJoinServiceTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export const useJoinServiceTx = (options?: UseJoinServiceTxOptions) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TANGLE_ABI, + (params: JoinServiceParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'joinService' as const, + args: [params.serviceId, params.exposureBps] as const, + }), + { + txName: 'join service', + txDetails: (params) => + new Map([ + ['Service ID', params.serviceId.toString()], + ['Exposure', `${(params.exposureBps / 100).toFixed(2)}%`], + ]), + getSuccessMessage: (params) => + `Successfully joined service #${params.serviceId}`, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['serviceOperators'] }); + queryClient.invalidateQueries({ queryKey: ['serviceDetails'] }); + queryClient.invalidateQueries({ queryKey: ['exitStatus'] }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useJoinServiceTx; diff --git a/apps/tangle-cloud/src/data/services/useJoinServiceWithCommitmentsTx.ts b/apps/tangle-cloud/src/data/services/useJoinServiceWithCommitmentsTx.ts new file mode 100644 index 0000000000..2c5ae8b264 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useJoinServiceWithCommitmentsTx.ts @@ -0,0 +1,92 @@ +/** + * EVM hook for joining a service with security commitments via the Tangle contract. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; +import { Address } from 'viem'; +import { AssetKind } from '@tangle-network/tangle-shared-ui/data/services'; + +export { TxStatus }; + +export interface AssetSecurityCommitment { + asset: { + kind: AssetKind; + token: Address; + }; + exposureBps: number; +} + +export interface JoinServiceWithCommitmentsParams { + serviceId: bigint; + exposureBps: number; + commitments: AssetSecurityCommitment[]; +} + +export interface UseJoinServiceWithCommitmentsTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export const useJoinServiceWithCommitmentsTx = ( + options?: UseJoinServiceWithCommitmentsTxOptions, +) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TANGLE_ABI, + (params: JoinServiceWithCommitmentsParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'joinServiceWithCommitments' as const, + args: [ + params.serviceId, + params.exposureBps, + params.commitments.map((c) => ({ + asset: { + kind: c.asset.kind, + token: c.asset.token, + }, + exposureBps: c.exposureBps, + })), + ] as const, + }), + { + txName: 'join service with commitments', + txDetails: (params) => + new Map([ + ['Service ID', params.serviceId.toString()], + ['Exposure', `${(params.exposureBps / 100).toFixed(2)}%`], + ['Commitments', params.commitments.length.toString()], + ]), + getSuccessMessage: (params) => + `Successfully joined service #${params.serviceId} with security commitments`, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['serviceOperators'] }); + queryClient.invalidateQueries({ queryKey: ['serviceDetails'] }); + queryClient.invalidateQueries({ queryKey: ['exitStatus'] }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useJoinServiceWithCommitmentsTx; diff --git a/apps/tangle-cloud/src/data/services/useLeaveServiceTx.ts b/apps/tangle-cloud/src/data/services/useLeaveServiceTx.ts new file mode 100644 index 0000000000..42e6209812 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useLeaveServiceTx.ts @@ -0,0 +1,65 @@ +/** + * EVM hook for leaving a service directly via the Tangle contract. + * This is used when exitQueueDuration is 0 (no exit queue required). + */ + +import { useQueryClient } from '@tanstack/react-query'; +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; + +export { TxStatus }; + +export interface LeaveServiceParams { + serviceId: bigint; +} + +export interface UseLeaveServiceTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export const useLeaveServiceTx = (options?: UseLeaveServiceTxOptions) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TANGLE_ABI, + (params: LeaveServiceParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'leaveService' as const, + args: [params.serviceId] as const, + }), + { + txName: 'leave service', + txDetails: (params) => + new Map([['Service ID', params.serviceId.toString()]]), + getSuccessMessage: (params) => + `Successfully left service #${params.serviceId}`, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['serviceOperators'] }); + queryClient.invalidateQueries({ queryKey: ['serviceDetails'] }); + queryClient.invalidateQueries({ queryKey: ['exitStatus'] }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useLeaveServiceTx; diff --git a/apps/tangle-cloud/src/data/services/useOperatorRegisterTx.ts b/apps/tangle-cloud/src/data/services/useOperatorRegisterTx.ts new file mode 100644 index 0000000000..81ddfac24f --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useOperatorRegisterTx.ts @@ -0,0 +1,41 @@ +/** + * EVM hook for batch registering an operator for multiple blueprints. + * @deprecated TODO: Implement using proper Tangle contract ABI + */ + +import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import type { PrimitiveField } from '@tangle-network/tangle-shared-ui/types/blueprint'; + +export interface OperatorBatchRegisterParams { + blueprintIds: bigint[]; + ecdsaPublicKey: `0x${string}`; + rpcAddress: string; + registrationArgs: (PrimitiveField[] | undefined)[]; + amounts: string[]; +} + +/** + * Hook for batch registering as an operator for multiple blueprints. + */ +export const useOperatorBatchRegisterTx = () => { + const execute = async ( + _params: OperatorBatchRegisterParams, + ): Promise => { + console.warn( + 'useOperatorBatchRegisterTx is not yet implemented for EVM Tangle contract', + ); + return null; + }; + + return { + execute, + status: TxStatus.NOT_YET_INITIATED, + error: null, + reset: () => { + // No-op: stub implementation + }, + txHash: null, + isSuccess: false, + isPending: false, + }; +}; diff --git a/apps/tangle-cloud/src/data/services/useScheduleExitTx.ts b/apps/tangle-cloud/src/data/services/useScheduleExitTx.ts new file mode 100644 index 0000000000..049230f778 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useScheduleExitTx.ts @@ -0,0 +1,64 @@ +/** + * EVM hook for scheduling an operator exit from a service via the Tangle contract. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; + +export { TxStatus }; + +export interface ScheduleExitParams { + serviceId: bigint; +} + +export interface UseScheduleExitTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export const useScheduleExitTx = (options?: UseScheduleExitTxOptions) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TANGLE_ABI, + (params: ScheduleExitParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'scheduleExit' as const, + args: [params.serviceId] as const, + }), + { + txName: 'schedule exit', + txDetails: (params) => + new Map([['Service ID', params.serviceId.toString()]]), + getSuccessMessage: (params) => + `Successfully scheduled exit from service #${params.serviceId}`, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exitStatus'] }); + queryClient.invalidateQueries({ queryKey: ['exitRequest'] }); + queryClient.invalidateQueries({ queryKey: ['canScheduleExit'] }); + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useScheduleExitTx; diff --git a/apps/tangle-cloud/src/data/services/useServiceApproveTx.ts b/apps/tangle-cloud/src/data/services/useServiceApproveTx.ts new file mode 100644 index 0000000000..e6f39f05d8 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useServiceApproveTx.ts @@ -0,0 +1,162 @@ +/** + * EVM hook for approving a service request via the Tangle contract. + * + * Supports two approval modes: + * - Simple approval: When no custom security requirements exist + * - Approval with commitments: When custom asset requirements are defined + */ + +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; +import type { ContractSecurityCommitment } from '../../types'; + +export { TxStatus }; + +/** + * Parameters for simple approval (no custom requirements) + */ +export interface SimpleApproveParams { + requestId: bigint; + restakingPercent: number; +} + +/** + * Parameters for approval with commitments (custom requirements) + */ +export interface CommitmentsApproveParams { + requestId: bigint; + securityCommitments: ContractSecurityCommitment[]; +} + +export type ServiceApproveParams = + | SimpleApproveParams + | CommitmentsApproveParams; + +/** + * Options for the useServiceApproveTx hook + */ +export interface UseServiceApproveTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +/** + * Type guard to check if params include security commitments + */ +const hasSecurityCommitments = ( + params: ServiceApproveParams, +): params is CommitmentsApproveParams => { + return ( + 'securityCommitments' in params && + Array.isArray(params.securityCommitments) && + params.securityCommitments.length > 0 + ); +}; + +/** + * Hook for approving a service request. + * + * Uses the appropriate contract method based on provided parameters: + * - `approveService` when only restakingPercent is provided (simple approval) + * - `approveServiceWithCommitments` when securityCommitments are provided + * + * @example + * ```tsx + * const { execute, status, error } = useServiceApproveTx(); + * + * // Simple approval (no custom requirements) + * await execute({ + * requestId: 1n, + * restakingPercent: 50, + * }); + * + * // Approval with commitments (custom requirements) + * await execute({ + * requestId: 1n, + * securityCommitments: [ + * { asset: { kind: 1, token: '0x...' }, exposureBps: 7500 }, + * ], + * }); + * ``` + */ +export const useServiceApproveTx = (options?: UseServiceApproveTxOptions) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + + const hook = useContractWrite( + TANGLE_ABI, + async (params: ServiceApproveParams, _activeAddress) => { + // Check if we have security commitments (commitments mode) + if (hasSecurityCommitments(params)) { + // Format commitments for the contract + const commitments = params.securityCommitments.map((c) => ({ + asset: { + kind: c.asset.kind, + token: c.asset.token, + }, + exposureBps: c.exposureBps, + })); + + return { + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'approveServiceWithCommitments' as const, + args: [params.requestId, commitments] as const, + }; + } + + // Simple approval mode + const simpleParams = params as SimpleApproveParams; + const restakingPercent = Math.min( + 100, + Math.max(0, simpleParams.restakingPercent ?? 0), + ); + + return { + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'approveService' as const, + args: [params.requestId, restakingPercent] as const, + }; + }, + { + txName: 'approve service', + txDetails: (params) => { + const details = new Map(); + details.set('Request ID', params.requestId.toString()); + + if (hasSecurityCommitments(params)) { + details.set( + 'Commitments', + `${params.securityCommitments.length} asset(s)`, + ); + } else { + const simpleParams = params as SimpleApproveParams; + details.set('Restaking Percent', `${simpleParams.restakingPercent}%`); + } + + return details; + }, + getSuccessMessage: (params) => + `Successfully approved service request #${params.requestId}`, + onSuccess: options?.onSuccess, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useServiceApproveTx; diff --git a/apps/tangle-cloud/src/data/services/useServiceRejectTx.ts b/apps/tangle-cloud/src/data/services/useServiceRejectTx.ts new file mode 100644 index 0000000000..37407735f8 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useServiceRejectTx.ts @@ -0,0 +1,72 @@ +/** + * EVM hook for rejecting a service request via the Tangle contract. + */ + +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; + +export { TxStatus }; + +export interface ServiceRejectParams { + requestId: bigint; +} + +/** + * Options for the useServiceRejectTx hook + */ +export interface UseServiceRejectTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +/** + * Hook for rejecting a service request. + * + * @example + * ```tsx + * const { execute, status, error } = useServiceRejectTx(); + * + * await execute({ + * requestId: 1n, + * }); + * ``` + */ +export const useServiceRejectTx = (options?: UseServiceRejectTxOptions) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + + const hook = useContractWrite( + TANGLE_ABI, + (params: ServiceRejectParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'rejectService' as const, + args: [params.requestId] as const, + }), + { + txName: 'reject service', + txDetails: (params) => + new Map([['Request ID', params.requestId.toString()]]), + getSuccessMessage: (params) => + `Successfully rejected service request #${params.requestId}`, + onSuccess: options?.onSuccess, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useServiceRejectTx; diff --git a/apps/tangle-cloud/src/data/services/useServiceRequestSecurityRequirements.ts b/apps/tangle-cloud/src/data/services/useServiceRequestSecurityRequirements.ts new file mode 100644 index 0000000000..e905221181 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useServiceRequestSecurityRequirements.ts @@ -0,0 +1,233 @@ +/** + * Hook to query security requirements for a service request from the contract + * and resolve token metadata for display. + */ + +import { useQuery } from '@tanstack/react-query'; +import { useChainId, usePublicClient } from 'wagmi'; +import { Address, zeroAddress } from 'viem'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { useEvmAssetMetadatas } from '@tangle-network/tangle-shared-ui/hooks/useEvmAssetMetadatas'; +import { useMemo } from 'react'; +import type { EvmAddress } from '@tangle-network/ui-components/types/address'; + +// Default TNT requirement constants (must match contract) +const DEFAULT_TNT_MIN_EXPOSURE_BPS = 1000; // 10% +const DEFAULT_TNT_MAX_EXPOSURE_BPS = 10000; // 100% +const ASSET_KIND_ERC20 = 1; + +// Contract return types matching the Solidity struct +interface ContractAsset { + kind: number; // 0 = Native, 1 = ERC20 + token: Address; +} + +interface ContractSecurityRequirement { + asset: ContractAsset; + minExposureBps: number; // 0-10000 basis points + maxExposureBps: number; // 0-10000 basis points +} + +// Enriched requirement with resolved token metadata +export interface SecurityRequirementWithMetadata { + asset: ContractAsset; + minExposureBps: number; + maxExposureBps: number; + metadata: { + name: string; + symbol: string; + decimals: number; + } | null; +} + +interface UseServiceRequestSecurityRequirementsResult { + data: SecurityRequirementWithMetadata[] | undefined; + isLoading: boolean; + error: Error | null; + /** True if custom requirements exist beyond default TNT */ + hasCustomRequirements: boolean; + /** True if this is the simple case (only default TNT requirement) */ + isSimpleCase: boolean; + /** The default TNT requirement info (for simple case UI) */ + defaultTntRequirement: SecurityRequirementWithMetadata | null; +} + +/** + * Queries security requirements for a service request and resolves token metadata. + * + * @param requestId - The service request ID to query requirements for + * @returns Security requirements with resolved token metadata + * + * @example + * ```tsx + * const { data: requirements, isLoading, hasRequirements } = + * useServiceRequestSecurityRequirements(requestId); + * + * if (hasRequirements) { + * // Show per-asset commitment inputs + * } else { + * // Show simple restaking percentage input + * } + * ``` + */ +export const useServiceRequestSecurityRequirements = ( + requestId: bigint | undefined, +): UseServiceRequestSecurityRequirementsResult => { + const chainId = useChainId(); + const publicClient = usePublicClient(); + const contracts = getContractsByChainId(chainId); + + // Query TNT token address from contract + const { data: tntTokenAddress, isLoading: isLoadingTntToken } = useQuery({ + queryKey: ['tntTokenAddress', chainId], + queryFn: async () => { + if (!publicClient) { + return null; + } + + try { + const result = await publicClient.readContract({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'tntToken', + args: [], + }); + + return result as Address; + } catch (error) { + console.warn( + '[useServiceRequestSecurityRequirements] Failed to fetch TNT token address', + { error }, + ); + return null; + } + }, + enabled: publicClient !== undefined, + staleTime: Infinity, // TNT token address doesn't change + }); + + // Query raw requirements from contract + const { + data: rawRequirements, + isLoading: isLoadingRequirements, + error: requirementsError, + } = useQuery({ + queryKey: [ + 'serviceRequestSecurityRequirements', + requestId?.toString(), + chainId, + ], + queryFn: async () => { + if (!publicClient || requestId === undefined) { + return []; + } + + try { + const result = await publicClient.readContract({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'getServiceRequestSecurityRequirements', + args: [requestId], + }); + + return result as ContractSecurityRequirement[]; + } catch (error) { + console.warn( + '[useServiceRequestSecurityRequirements] Failed to fetch requirements', + { requestId, error }, + ); + return []; + } + }, + enabled: requestId !== undefined && publicClient !== undefined, + staleTime: 30_000, // 30 seconds + }); + + // Extract unique token addresses for metadata resolution + const tokenAddresses = useMemo(() => { + if (!rawRequirements || rawRequirements.length === 0) { + return null; + } + + return rawRequirements.map((req) => { + // For native token (kind=0), use zero address for metadata lookup + // For ERC20 (kind=1), use the token address + const addr = req.asset.kind === 0 ? zeroAddress : req.asset.token; + return addr as EvmAddress; + }); + }, [rawRequirements]); + + // Fetch token metadata + const { data: tokenMetadatas, isLoading: isLoadingMetadata } = + useEvmAssetMetadatas(tokenAddresses); + + // Combine requirements with metadata + const requirementsWithMetadata = useMemo< + SecurityRequirementWithMetadata[] | undefined + >(() => { + if (!rawRequirements) { + return undefined; + } + + if (rawRequirements.length === 0) { + return []; + } + + return rawRequirements.map((req, index) => { + const metadata = tokenMetadatas?.[index] ?? null; + + return { + asset: req.asset, + minExposureBps: Number(req.minExposureBps), + maxExposureBps: Number(req.maxExposureBps), + metadata: metadata + ? { + name: metadata.name, + symbol: metadata.symbol, + decimals: metadata.decimals, + } + : null, + }; + }); + }, [rawRequirements, tokenMetadatas]); + + // Check if this is the simple case: only the default TNT requirement + // Mirrors contract's _isOnlyDefaultTntRequirement logic + const { isSimpleCase, defaultTntRequirement } = useMemo(() => { + if ( + !requirementsWithMetadata || + requirementsWithMetadata.length !== 1 || + !tntTokenAddress || + tntTokenAddress === zeroAddress + ) { + return { isSimpleCase: false, defaultTntRequirement: null }; + } + + const req = requirementsWithMetadata[0]; + const isDefaultTnt = + req.asset.kind === ASSET_KIND_ERC20 && + req.asset.token.toLowerCase() === tntTokenAddress.toLowerCase() && + req.minExposureBps === DEFAULT_TNT_MIN_EXPOSURE_BPS && + req.maxExposureBps === DEFAULT_TNT_MAX_EXPOSURE_BPS; + + return { + isSimpleCase: isDefaultTnt, + defaultTntRequirement: isDefaultTnt ? req : null, + }; + }, [requirementsWithMetadata, tntTokenAddress]); + + return { + data: requirementsWithMetadata, + isLoading: isLoadingRequirements || isLoadingMetadata || isLoadingTntToken, + error: requirementsError as Error | null, + hasCustomRequirements: + requirementsWithMetadata !== undefined && + requirementsWithMetadata.length > 0 && + !isSimpleCase, + isSimpleCase, + defaultTntRequirement, + }; +}; + +export default useServiceRequestSecurityRequirements; diff --git a/apps/tangle-cloud/src/data/services/useServiceSecurityRequirements.ts b/apps/tangle-cloud/src/data/services/useServiceSecurityRequirements.ts new file mode 100644 index 0000000000..7a79831f2c --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useServiceSecurityRequirements.ts @@ -0,0 +1,209 @@ +/** + * Hook to query security requirements for an active service from the contract + * and resolve token metadata for display. + * + * Mirrors useServiceRequestSecurityRequirements but calls + * getServiceSecurityRequirements(serviceId) instead of the request variant. + */ + +import { useQuery } from '@tanstack/react-query'; +import { useChainId, usePublicClient } from 'wagmi'; +import { Address, zeroAddress } from 'viem'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { useEvmAssetMetadatas } from '@tangle-network/tangle-shared-ui/hooks/useEvmAssetMetadatas'; +import { useMemo } from 'react'; +import type { EvmAddress } from '@tangle-network/ui-components/types/address'; +import type { SecurityRequirementWithMetadata } from './useServiceRequestSecurityRequirements'; + +// Default TNT requirement constants (must match contract) +const DEFAULT_TNT_MIN_EXPOSURE_BPS = 1000; // 10% +const DEFAULT_TNT_MAX_EXPOSURE_BPS = 10000; // 100% +const ASSET_KIND_ERC20 = 1; + +// Contract return types matching the Solidity struct +interface ContractAsset { + kind: number; // 0 = Native, 1 = ERC20 + token: Address; +} + +interface ContractSecurityRequirement { + asset: ContractAsset; + minExposureBps: number; // 0-10000 basis points + maxExposureBps: number; // 0-10000 basis points +} + +interface UseServiceSecurityRequirementsResult { + data: SecurityRequirementWithMetadata[] | undefined; + isLoading: boolean; + error: Error | null; + /** True if custom requirements exist beyond default TNT */ + hasCustomRequirements: boolean; + /** True if this is the simple case (only default TNT requirement) */ + isSimpleCase: boolean; + /** The default TNT requirement info (for simple case UI) */ + defaultTntRequirement: SecurityRequirementWithMetadata | null; +} + +/** + * Queries security requirements for an active service and resolves token metadata. + * + * @param serviceId - The service ID to query requirements for + * @returns Security requirements with resolved token metadata + */ +export const useServiceSecurityRequirements = ( + serviceId: bigint | undefined, +): UseServiceSecurityRequirementsResult => { + const chainId = useChainId(); + const publicClient = usePublicClient(); + const contracts = getContractsByChainId(chainId); + + // Query TNT token address from contract + const { data: tntTokenAddress, isLoading: isLoadingTntToken } = useQuery({ + queryKey: ['tntTokenAddress', chainId], + queryFn: async () => { + if (!publicClient) { + return null; + } + + try { + const result = await publicClient.readContract({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'tntToken', + args: [], + }); + + return result as Address; + } catch (error) { + console.warn( + '[useServiceSecurityRequirements] Failed to fetch TNT token address', + { error }, + ); + return null; + } + }, + enabled: publicClient !== undefined, + staleTime: Infinity, // TNT token address doesn't change + }); + + // Query raw requirements from contract + const { + data: rawRequirements, + isLoading: isLoadingRequirements, + error: requirementsError, + } = useQuery({ + queryKey: ['serviceSecurityRequirements', serviceId?.toString(), chainId], + queryFn: async () => { + if (!publicClient || serviceId === undefined) { + return []; + } + + try { + const result = await publicClient.readContract({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'getServiceSecurityRequirements', + args: [serviceId], + }); + + return result as ContractSecurityRequirement[]; + } catch (error) { + console.warn( + '[useServiceSecurityRequirements] Failed to fetch requirements', + { serviceId, error }, + ); + return []; + } + }, + enabled: serviceId !== undefined && publicClient !== undefined, + staleTime: 30_000, // 30 seconds + }); + + // Extract unique token addresses for metadata resolution + const tokenAddresses = useMemo(() => { + if (!rawRequirements || rawRequirements.length === 0) { + return null; + } + + return rawRequirements.map((req) => { + // For native token (kind=0), use zero address for metadata lookup + // For ERC20 (kind=1), use the token address + const addr = req.asset.kind === 0 ? zeroAddress : req.asset.token; + return addr as EvmAddress; + }); + }, [rawRequirements]); + + // Fetch token metadata + const { data: tokenMetadatas, isLoading: isLoadingMetadata } = + useEvmAssetMetadatas(tokenAddresses); + + // Combine requirements with metadata + const requirementsWithMetadata = useMemo< + SecurityRequirementWithMetadata[] | undefined + >(() => { + if (!rawRequirements) { + return undefined; + } + + if (rawRequirements.length === 0) { + return []; + } + + return rawRequirements.map((req, index) => { + const metadata = tokenMetadatas?.[index] ?? null; + + return { + asset: req.asset, + minExposureBps: Number(req.minExposureBps), + maxExposureBps: Number(req.maxExposureBps), + metadata: metadata + ? { + name: metadata.name, + symbol: metadata.symbol, + decimals: metadata.decimals, + } + : null, + }; + }); + }, [rawRequirements, tokenMetadatas]); + + // Check if this is the simple case: only the default TNT requirement + // Mirrors contract's _isOnlyDefaultTntRequirement logic + const { isSimpleCase, defaultTntRequirement } = useMemo(() => { + if ( + !requirementsWithMetadata || + requirementsWithMetadata.length !== 1 || + !tntTokenAddress || + tntTokenAddress === zeroAddress + ) { + return { isSimpleCase: false, defaultTntRequirement: null }; + } + + const req = requirementsWithMetadata[0]; + const isDefaultTnt = + req.asset.kind === ASSET_KIND_ERC20 && + req.asset.token.toLowerCase() === tntTokenAddress.toLowerCase() && + req.minExposureBps === DEFAULT_TNT_MIN_EXPOSURE_BPS && + req.maxExposureBps === DEFAULT_TNT_MAX_EXPOSURE_BPS; + + return { + isSimpleCase: isDefaultTnt, + defaultTntRequirement: isDefaultTnt ? req : null, + }; + }, [requirementsWithMetadata, tntTokenAddress]); + + return { + data: requirementsWithMetadata, + isLoading: isLoadingRequirements || isLoadingMetadata || isLoadingTntToken, + error: requirementsError as Error | null, + hasCustomRequirements: + requirementsWithMetadata !== undefined && + requirementsWithMetadata.length > 0 && + !isSimpleCase, + isSimpleCase, + defaultTntRequirement, + }; +}; + +export default useServiceSecurityRequirements; diff --git a/apps/tangle-cloud/src/data/services/useServiceTerminateTx.ts b/apps/tangle-cloud/src/data/services/useServiceTerminateTx.ts new file mode 100644 index 0000000000..f88e56c035 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useServiceTerminateTx.ts @@ -0,0 +1,142 @@ +/** + * EVM hook for terminating a service via the Tangle contract. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import useContractWrite, { + TxStatus, +} from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; +import TANGLE_ABI from '@tangle-network/tangle-shared-ui/abi/tangle'; +import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; +import { useChainId } from 'wagmi'; +import type { Service } from '@tangle-network/tangle-shared-ui/data/graphql'; + +export { TxStatus }; + +export interface ServiceTerminateParams { + serviceId: bigint; +} + +export interface UseServiceTerminateTxOptions { + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +// Backoff windows for Envio reconciliation after terminateService. +const ENVIO_REFETCH_BACKOFF_MS = [2_000, 5_000, 10_000, 20_000] as const; + +export const useServiceTerminateTx = ( + options?: UseServiceTerminateTxOptions, +) => { + const chainId = useChainId(); + const contracts = getContractsByChainId(chainId); + const queryClient = useQueryClient(); + + const hook = useContractWrite( + TANGLE_ABI, + (params: ServiceTerminateParams, _activeAddress) => ({ + address: contracts.tangle, + abi: TANGLE_ABI, + functionName: 'terminateService' as const, + args: [params.serviceId] as const, + }), + { + txName: 'terminate service', + txDetails: (params) => + new Map([['Service ID', params.serviceId.toString()]]), + getSuccessMessage: (params) => + `Successfully terminated service #${params.serviceId}`, + onSuccess: (_result, params) => { + const removeFromActiveServiceQueries = () => { + let serviceStillAppearsAsActive = false; + const serviceQueries = queryClient + .getQueryCache() + .findAll({ queryKey: ['envio', 'services'] }); + + for (const query of serviceQueries) { + const key = query.queryKey as unknown[]; + const status = key[4]; + if (status !== 'ACTIVE') continue; + + queryClient.setQueryData(key, (old) => { + if (!Array.isArray(old)) return old; + + const services = old as Service[]; + const containsTerminatedService = services.some( + (service) => service.serviceId === params.serviceId, + ); + + if (!containsTerminatedService) return old; + + serviceStillAppearsAsActive = true; + return services.filter( + (service) => service.serviceId !== params.serviceId, + ); + }); + } + + return serviceStillAppearsAsActive; + }; + + // Optimistically remove the service from any ACTIVE service lists so the + // Running Instances table updates immediately, even before Envio indexes + // the termination event. + removeFromActiveServiceQueries(); + + // On-chain queries: safe to invalidate immediately. + queryClient.invalidateQueries({ queryKey: ['serviceDetails'] }); + queryClient.invalidateQueries({ queryKey: ['serviceEscrow'] }); + + // Envio queries: refetch with a short backoff so we don't immediately + // re-introduce stale indexer results, but still converge once indexed. + queryClient.invalidateQueries({ + queryKey: ['envio', 'services'], + refetchType: 'none', + }); + queryClient.invalidateQueries({ + queryKey: ['envio', 'operatorStats'], + refetchType: 'none', + }); + + let keepOptimisticRemoval = true; + + const refetchEnvio = async () => { + await queryClient.refetchQueries({ queryKey: ['envio', 'services'] }); + + if (keepOptimisticRemoval) { + const serviceStillAppearsAsActive = + removeFromActiveServiceQueries(); + if (!serviceStillAppearsAsActive) { + keepOptimisticRemoval = false; + } + } + + void queryClient.refetchQueries({ + queryKey: ['envio', 'operatorStats'], + }); + }; + + ENVIO_REFETCH_BACKOFF_MS.forEach((delayMs) => { + setTimeout(() => { + void refetchEnvio(); + }, delayMs); + }); + + options?.onSuccess?.(); + }, + onError: options?.onError, + }, + ); + + return { + execute: hook.execute, + status: hook.status, + error: hook.error, + reset: hook.reset, + txHash: hook.txHash, + isSuccess: hook.isSuccess, + isPending: hook.isLoading, + }; +}; + +export default useServiceTerminateTx; diff --git a/apps/tangle-cloud/src/data/services/useServicesApproveTx.ts b/apps/tangle-cloud/src/data/services/useServicesApproveTx.ts deleted file mode 100644 index 37105be8fa..0000000000 --- a/apps/tangle-cloud/src/data/services/useServicesApproveTx.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SUCCESS_MESSAGES } from '../../hooks/useTxNotification'; -import { useCallback } from 'react'; - -import { TxName } from '../../constants'; -import { - SubstrateTxFactory, - useSubstrateTxWithNotification, -} from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx'; -import { ApprovalConfirmationFormFields } from '../../types'; -import createAssetIdEnum from '@tangle-network/tangle-shared-ui/utils/createAssetIdEnum'; - -type Context = ApprovalConfirmationFormFields; - -const useServicesApproveTx = () => { - const substrateTxFactory: SubstrateTxFactory = useCallback( - (api, _activeSubstrateAddress, context) => { - const securityCommitments = context.securityCommitment.map( - (commitment) => ({ - asset: createAssetIdEnum(commitment.assetId), - exposurePercent: commitment.exposurePercent, - }), - ); - - return api.tx.services.approve(context.requestId, securityCommitments); - }, - [], - ); - - return useSubstrateTxWithNotification( - TxName.APPROVE_SERVICE_REQUEST, - substrateTxFactory, - SUCCESS_MESSAGES, - ); -}; - -export default useServicesApproveTx; diff --git a/apps/tangle-cloud/src/data/services/useServicesRegisterTx.ts b/apps/tangle-cloud/src/data/services/useServicesRegisterTx.ts deleted file mode 100644 index 78931141f9..0000000000 --- a/apps/tangle-cloud/src/data/services/useServicesRegisterTx.ts +++ /dev/null @@ -1,48 +0,0 @@ -import optimizeTxBatch from '@tangle-network/tangle-shared-ui/utils/optimizeTxBatch'; -import { SUCCESS_MESSAGES } from '../../hooks/useTxNotification'; -import { useCallback } from 'react'; - -import { TxName } from '../../constants'; -import { - SubstrateTxFactory, - useSubstrateTxWithNotification, -} from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx'; -import { RegisterServiceFormFields } from '../../types'; -import { TANGLE_TOKEN_DECIMALS } from '@tangle-network/dapp-config'; -import { parseUnits } from 'viem'; - -type Context = RegisterServiceFormFields; - -const useServicesRegisterTx = () => { - const substrateTxFactory: SubstrateTxFactory = useCallback( - async (api, _activeSubstrateAddress, context) => { - const { blueprintIds, preferences, registrationArgs, amounts } = context; - - // TODO: Find a better way to get the chain decimals - const decimals = - api.registry.chainDecimals.length > 0 - ? api.registry.chainDecimals[0] - : TANGLE_TOKEN_DECIMALS; - - const registerTx = blueprintIds.map((blueprintId, idx) => { - return api.tx.services.register( - blueprintId, - preferences[idx], - registrationArgs[idx], - parseUnits(amounts[idx].toString(), decimals), - ); - }); - - return optimizeTxBatch(api, registerTx); - }, - [], - ); - - return useSubstrateTxWithNotification( - TxName.REGISTER_BLUEPRINT, - substrateTxFactory, - SUCCESS_MESSAGES, - ); -}; - -export default useServicesRegisterTx; diff --git a/apps/tangle-cloud/src/data/services/useServicesRejectTx.ts b/apps/tangle-cloud/src/data/services/useServicesRejectTx.ts deleted file mode 100644 index 106ca24848..0000000000 --- a/apps/tangle-cloud/src/data/services/useServicesRejectTx.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { SUCCESS_MESSAGES } from '../../hooks/useTxNotification'; -import { useCallback } from 'react'; - -import { TxName } from '../../constants'; -import { - SubstrateTxFactory, - useSubstrateTxWithNotification, -} from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx'; - -type Context = { - requestId: bigint; -}; - -const useServicesRejectTx = () => { - const substrateTxFactory: SubstrateTxFactory = useCallback( - (api, _activeSubstrateAddress, context) => - api.tx.services.reject(context.requestId), - [], - ); - - return useSubstrateTxWithNotification( - TxName.REJECT_SERVICE_REQUEST, - substrateTxFactory, - SUCCESS_MESSAGES, - ); -}; - -export default useServicesRejectTx; diff --git a/apps/tangle-cloud/src/data/services/useServicesRequestTx.ts b/apps/tangle-cloud/src/data/services/useServicesRequestTx.ts deleted file mode 100644 index c3280d9b4e..0000000000 --- a/apps/tangle-cloud/src/data/services/useServicesRequestTx.ts +++ /dev/null @@ -1,189 +0,0 @@ -import createAssetIdEnum from '@tangle-network/tangle-shared-ui/utils/createAssetIdEnum'; -import useAgnosticTx from '@tangle-network/tangle-shared-ui/hooks/useAgnosticTx'; -import { SUCCESS_MESSAGES } from '../../hooks/useTxNotification'; -import { useCallback } from 'react'; - -import { TxName } from '../../constants'; -import { SubstrateTxFactory } from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx'; -import SERVICES_PRECOMPILE_ABI from '@tangle-network/tangle-shared-ui/abi/services'; -import { PrecompileAddress } from '@tangle-network/tangle-shared-ui/constants/evmPrecompiles'; -import { - EvmAddress, - SubstrateAddress, -} from '@tangle-network/ui-components/types/address'; -import { PrimitiveField } from '@tangle-network/tangle-shared-ui/types/blueprint'; -import { RestakeAssetId } from '@tangle-network/tangle-shared-ui/types'; -import { EvmTxFactory } from '@tangle-network/tangle-shared-ui/hooks/useEvmPrecompileCall'; -import { Hash, zeroAddress } from 'viem'; -import { - assertEvmAddress, - isEvmAddress, - toEvmAddress, - toSubstrateAddress, -} from '@tangle-network/ui-components'; - -import { decodeAddress } from '@polkadot/util-crypto'; -import createMembershipModelEnum from '@tangle-network/tangle-shared-ui/utils/createMembershipModelEnum'; -import { ApiPromise } from '@polkadot/api'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; - -export type Context = { - blueprintId: bigint; - permittedCallers: Array; - operators: SubstrateAddress[]; - requestArgs: PrimitiveField[]; - securityRequirements: Array<{ - minExposurePercent: number; - maxExposurePercent: number; - }>; - assets: RestakeAssetId[]; - ttl: bigint; - paymentAsset: RestakeAssetId; - paymentValue: bigint; - membershipModel: 'Fixed' | 'Dynamic'; - minOperator: number; - maxOperator: number; -}; - -const useServicesRegisterTx = () => { - const { network } = useNetworkStore(); - - const substrateTxFactory: SubstrateTxFactory = useCallback( - async (api, _activeSubstrateAddress, context) => { - // Ensure EVM addresses are converted to their corresponding SS58 representation - // with the correct Tangle network SS58 prefix for consistency - const formatAccount = ( - addr: SubstrateAddress | EvmAddress, - ): SubstrateAddress => { - return isEvmAddress(addr) - ? toSubstrateAddress(addr, network.ss58Prefix) - : toSubstrateAddress(addr, network.ss58Prefix); - }; - - const formattedPermittedCallers = - context.permittedCallers.map(formatAccount); - const formattedOperators = context.operators.map(formatAccount); - - const paymentAsset = createAssetIdEnum(context.paymentAsset); - - const membershipModel = createMembershipModelEnum({ - type: context.membershipModel, - minOperators: context.minOperator, - maxOperators: context.maxOperator, - }); - - const assetSecurityRequirements = context.assets.map((asset, index) => ({ - asset: createAssetIdEnum(asset), - minExposurePercent: - context.securityRequirements[index].minExposurePercent, - maxExposurePercent: - context.securityRequirements[index].maxExposurePercent, - })); - - return (api.tx.services.request as any)( - null, // evm_origin (None) - context.blueprintId, - formattedPermittedCallers, - formattedOperators, - context.requestArgs, - assetSecurityRequirements, - context.ttl, - paymentAsset, - context.paymentValue, - membershipModel, - ); - }, - [network.ss58Prefix], - ); - - const evmTxFactory: EvmTxFactory< - typeof SERVICES_PRECOMPILE_ABI, - 'requestService', - Context & { apiPromise: ApiPromise } - > = useCallback( - async (context) => { - const api = context.apiPromise; - - const decodedPermittedCallers = context.permittedCallers.map((caller) => { - if (isEvmAddress(caller)) { - return decodeAddress(toSubstrateAddress(caller, network.ss58Prefix)); - } else { - return decodeAddress(toSubstrateAddress(caller, network.ss58Prefix)); - } - }); - const encodedPermittedCallers: Hash = api - .createType('Vec', decodedPermittedCallers) - .toHex(); - - const encodedAssetSecurityRequirements: Hash[] = context.assets.map( - (asset, index) => - api - .createType('AssetSecurityRequirement', { - asset: createAssetIdEnum(asset), - minExposurePercent: - context.securityRequirements[index].minExposurePercent, - maxExposurePercent: - context.securityRequirements[index].maxExposurePercent, - }) - .toHex(), - ); - - const decodedOperators = context.operators.map((operator) => { - if (isEvmAddress(operator)) { - return decodeAddress( - toSubstrateAddress(operator, network.ss58Prefix), - ); - } else { - return decodeAddress( - toSubstrateAddress(operator, network.ss58Prefix), - ); - } - }); - const encodedOperators = api - .createType('Vec', decodedOperators) - .toHex(); - - const encodedRequestArgs: Hash = api - .createType('Vec', context.requestArgs) - .toHex(); - - const isEvmAssetPayment = isEvmAddress(context.paymentAsset); - - const [paymentAssetId, paymentTokenAddress] = isEvmAssetPayment - ? [BigInt(0), toEvmAddress(context.paymentAsset as EvmAddress)] - : [ - BigInt(context.paymentAsset), - toEvmAddress(assertEvmAddress(zeroAddress)), - ]; - - return { - functionName: 'requestService', - arguments: [ - context.blueprintId, - encodedAssetSecurityRequirements, - encodedPermittedCallers, - encodedOperators, - encodedRequestArgs, - context.ttl, - paymentAssetId, - paymentTokenAddress, - context.paymentValue, - context.minOperator, - context.maxOperator, - ], - }; - }, - [network.ss58Prefix], - ); - - return useAgnosticTx({ - name: TxName.DEPLOY_BLUEPRINT, - abi: SERVICES_PRECOMPILE_ABI, - precompileAddress: PrecompileAddress.SERVICES, - evmTxFactory, - substrateTxFactory, - successMessageByTxName: SUCCESS_MESSAGES, - }); -}; - -export default useServicesRegisterTx; diff --git a/apps/tangle-cloud/src/data/services/useServicesTerminateTx.ts b/apps/tangle-cloud/src/data/services/useServicesTerminateTx.ts deleted file mode 100644 index 0ddf5d8cdb..0000000000 --- a/apps/tangle-cloud/src/data/services/useServicesTerminateTx.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { SUCCESS_MESSAGES } from '../../hooks/useTxNotification'; -import { useCallback } from 'react'; - -import { TxName } from '../../constants'; -import { - SubstrateTxFactory, - useSubstrateTxWithNotification, -} from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx'; - -type Context = { - instanceId: bigint; -}; - -const useServicesTerminateTx = () => { - const substrateTxFactory: SubstrateTxFactory = useCallback( - (api, _activeSubstrateAddress, context) => - api.tx.services.terminate(context.instanceId), - [], - ); - - return useSubstrateTxWithNotification( - TxName.TERMINATE_SERVICE_INSTANCE, - substrateTxFactory, - SUCCESS_MESSAGES, - ); -}; - -export default useServicesTerminateTx; diff --git a/apps/tangle-cloud/src/data/services/useUserOwnedInstances.ts b/apps/tangle-cloud/src/data/services/useUserOwnedInstances.ts deleted file mode 100644 index 1c3f9344e9..0000000000 --- a/apps/tangle-cloud/src/data/services/useUserOwnedInstances.ts +++ /dev/null @@ -1,182 +0,0 @@ -import useApiRx from '@tangle-network/tangle-shared-ui/hooks/useApiRx'; -import { SubstrateAddress } from '@tangle-network/ui-components/types/address'; -import { useCallback, useMemo } from 'react'; -import { catchError, combineLatest, map, of } from 'rxjs'; -import { MonitoringBlueprint } from '@tangle-network/tangle-shared-ui/data/blueprints/utils/type'; -import { toPrimitiveService } from '@tangle-network/tangle-shared-ui/data/blueprints/utils/toPrimitiveService'; -import { toPrimitiveBlueprint } from '@tangle-network/tangle-shared-ui/data/blueprints/utils/toPrimitiveBlueprint'; -import { Option, StorageKey, u64 } from '@polkadot/types'; -import { - TanglePrimitivesServicesService, - TanglePrimitivesServicesServiceServiceBlueprint, -} from '@polkadot/types/lookup'; -import { AccountId32 } from '@polkadot/types/interfaces'; -import { ITuple } from '@polkadot/types/types'; -import { encodeAddress, decodeAddress } from '@polkadot/util-crypto'; - -export const useUserOwnedInstances = ( - userAddress: SubstrateAddress | null | undefined, - refreshTrigger?: number, -) => { - const { result: userOwnedData, ...rest } = useApiRx( - useCallback( - (apiRx) => { - if (!userAddress) { - return of([]); - } - - // Get all service instances owned by the user - const userOwnedInstances$ = - apiRx.query.services?.instances === undefined - ? of([]) - : apiRx.query.services?.instances - .entries>() - .pipe( - map((instances) => { - return instances - .filter(([_, instance]) => { - if (instance.isNone) { - return false; - } - const detailed = instance.unwrap(); - const ownerAddress = detailed.owner.toString(); - - try { - const normalizedOwner = encodeAddress( - decodeAddress(ownerAddress), - ); - const normalizedUser = encodeAddress( - decodeAddress(userAddress), - ); - - return normalizedOwner === normalizedUser; - } catch (error) { - console.error('Address normalization error:', error); - return ownerAddress === userAddress; - } - }) - .map(([_key, instance]) => { - const primitiveService = toPrimitiveService( - instance.unwrap(), - ); - return primitiveService; - }); - }), - catchError((error) => { - console.error( - 'Error querying user owned service instances:', - error, - ); - return of([]); - }), - ); - - // Get blueprints data for the owned instances - const blueprints$ = - apiRx.query.services?.blueprints === undefined - ? of([]) - : apiRx.query.services?.blueprints - .entries< - Option< - ITuple< - [ - AccountId32, - TanglePrimitivesServicesServiceServiceBlueprint, - ] - > - > - >() - .pipe( - map((blueprints) => { - return blueprints - .filter(([_, blueprint]) => !blueprint.isNone) - .map(([key, blueprint]) => { - const [_, serviceBlueprint] = blueprint.unwrap(); - const blueprintId = ( - key as StorageKey<[u64]> - ).args[0].toBigInt(); - return toPrimitiveBlueprint( - blueprintId, - serviceBlueprint, - ); - }); - }), - catchError((error) => { - console.error( - 'Error querying blueprints for user owned instances:', - error, - ); - return of([]); - }), - ); - - return combineLatest([userOwnedInstances$, blueprints$]).pipe( - map(([userOwnedInstances, blueprints]) => { - // Create a map of blueprint ID to blueprint data - const blueprintMap = new Map( - blueprints.map((blueprint) => [ - blueprint.id.toString(), - blueprint, - ]), - ); - - // Transform to MonitoringBlueprint format - const monitoringBlueprints: MonitoringBlueprint[] = []; - const blueprintServicesMap = new Map< - string, - MonitoringBlueprint['services'] - >(); - - // Group services by blueprint - userOwnedInstances.forEach((service) => { - const blueprintId = service.blueprint.toString(); - const blueprintData = blueprintMap.get(blueprintId); - - if (blueprintData) { - const serviceWithBlueprint = { - ...service, - blueprintData, - }; - - if (!blueprintServicesMap.has(blueprintId)) { - blueprintServicesMap.set(blueprintId, []); - } - const services = blueprintServicesMap.get(blueprintId); - if (services) { - services.push(serviceWithBlueprint); - } - } - }); - - // Create MonitoringBlueprint objects - blueprintServicesMap.forEach((services, blueprintId) => { - const blueprintData = blueprintMap.get(blueprintId); - if (blueprintData) { - monitoringBlueprints.push({ - blueprintId: blueprintData.id, - blueprint: blueprintData, - services, - }); - } - }); - - return monitoringBlueprints; - }), - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [userAddress, refreshTrigger], - ), - ); - - const result = useMemo(() => { - return userOwnedData || []; - }, [userOwnedData]); - - return { - result, - ...rest, - }; -}; - -export default useUserOwnedInstances; diff --git a/apps/tangle-cloud/src/hooks/useEvmOperatorInfo.ts b/apps/tangle-cloud/src/hooks/useEvmOperatorInfo.ts new file mode 100644 index 0000000000..7131485a82 --- /dev/null +++ b/apps/tangle-cloud/src/hooks/useEvmOperatorInfo.ts @@ -0,0 +1,49 @@ +/** + * EVM version of useOperatorInfo hook. + * Checks if the connected wallet address is a registered operator. + */ + +import { useMemo } from 'react'; +import { Address } from 'viem'; +import { useAccount } from 'wagmi'; +import { useOperatorMap } from '@tangle-network/tangle-shared-ui/data/graphql'; + +export interface EvmOperatorInfo { + operatorAddress: Address | null; + isOperator: boolean; + isLoading: boolean; +} + +/** + * Hook to check if the connected EVM wallet is a registered operator. + */ +const useEvmOperatorInfo = (): EvmOperatorInfo => { + const { address } = useAccount(); + const { data: operatorMap, isLoading } = useOperatorMap(); + + const result = useMemo(() => { + if (!address || !operatorMap) { + return { + operatorAddress: null, + isOperator: false, + isLoading, + }; + } + + // Check if the address exists in the operator map (case-insensitive) + const normalizedAddress = address.toLowerCase(); + const isOperator = Array.from(operatorMap.keys()).some( + (opAddr) => opAddr.toLowerCase() === normalizedAddress, + ); + + return { + operatorAddress: isOperator ? address : null, + isOperator, + isLoading: false, + }; + }, [address, operatorMap, isLoading]); + + return result; +}; + +export default useEvmOperatorInfo; diff --git a/apps/tangle-cloud/src/hooks/useTxNotification.tsx b/apps/tangle-cloud/src/hooks/useTxNotification.tsx index 87d970035e..6195e0c7c0 100644 --- a/apps/tangle-cloud/src/hooks/useTxNotification.tsx +++ b/apps/tangle-cloud/src/hooks/useTxNotification.tsx @@ -2,11 +2,13 @@ import { TxName } from '../constants'; import useSharedTxNotification from '@tangle-network/tangle-shared-ui/hooks/useTxNotification'; export const SUCCESS_MESSAGES: Record = { + [TxName.REGISTER_BLUEPRINT]: 'Registered as operator successfully', + [TxName.UNREGISTER_BLUEPRINT]: 'Unregistered from blueprint successfully', [TxName.REJECT_SERVICE_REQUEST]: 'Service request rejected', [TxName.APPROVE_SERVICE_REQUEST]: 'Service request approved', - [TxName.REGISTER_BLUEPRINT]: 'Blueprint registered', - [TxName.DEPLOY_BLUEPRINT]: 'Blueprint deployed', + [TxName.DEPLOY_BLUEPRINT]: 'Blueprint deployed successfully', [TxName.TERMINATE_SERVICE_INSTANCE]: 'Service instance terminated', + [TxName.CLAIM_EARNINGS]: 'Earnings claimed successfully', }; const useTxNotification = () => { diff --git a/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx b/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx index 69f29b158f..96e63a09e6 100644 --- a/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx @@ -1,5 +1,5 @@ import BlueprintGallery from '@tangle-network/tangle-shared-ui/components/blueprints/BlueprintGallery'; -import useAllBlueprints from '@tangle-network/tangle-shared-ui/data/blueprints/useAllBlueprints'; +import { type UseAllBlueprintsReturn } from '@tangle-network/tangle-shared-ui/data/graphql'; import { RowSelectionState } from '@tanstack/table-core'; import { ComponentProps, @@ -22,7 +22,7 @@ const BlueprintItemWrapper: FC> = ({ type Props = { rowSelection?: RowSelectionState; onRowSelectionChange?: Dispatch>; -} & ReturnType; +} & Omit; const BlueprintListing: FC = ({ rowSelection, diff --git a/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/ConfigureBlueprintModal.tsx b/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/ConfigureBlueprintModal.tsx deleted file mode 100644 index dd8071938e..0000000000 --- a/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/ConfigureBlueprintModal.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; -import { Form } from '@tangle-network/ui-components/components/form'; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@tangle-network/ui-components/components/form'; -import { Input } from '@tangle-network/ui-components'; -import { - ModalBody, - ModalContent, - ModalHeader, -} from '@tangle-network/ui-components/components/Modal'; -import { FC, useCallback } from 'react'; -import { useForm } from 'react-hook-form'; -import { - blueprintFormSchema, - BlueprintFormSchema, - BlueprintFormResult, -} from './types'; -import FormActions from './FormActions'; - -type Props = { - onOpenChange: (isOpen: boolean) => void; - blueprints: Blueprint[]; - onSubmit: (result: BlueprintFormResult) => void; -}; - -const ConfigureBlueprintModal: FC = ({ - onOpenChange, - blueprints, - onSubmit, -}) => { - const form = useForm({ - resolver: zodResolver(blueprintFormSchema), - defaultValues: { - rpcUrl: '', - }, - }); - - const handleClose = useCallback(() => { - onOpenChange(false); - form.reset(); - }, [form, onOpenChange]); - - const handleSubmit = useCallback( - (values: BlueprintFormSchema) => { - handleClose(); - onSubmit({ - ...values, - blueprints, - }); - }, - [handleClose, onSubmit, blueprints], - ); - - return ( - event.preventDefault()} - title="Configure Blueprint" - description={`Configure the RPC URL for ${blueprints.length} selected blueprint${blueprints.length > 1 ? 's' : ''}`} - > - - Configure Blueprint{blueprints.length > 1 ? 's' : ''} - - - -
- - {blueprints.length > 1 ? ( -
-

- The following blueprints will be configured with the same RPC - URL: -

-
    - {blueprints.map((blueprint) => ( -
  • - {blueprint.name} -
  • - ))} -
-
- ) : null} - - ( - - Enter RPC URL - - - - - - )} - /> - - - - -
-
- ); -}; - -export default ConfigureBlueprintModal; diff --git a/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/FormActions.tsx b/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/FormActions.tsx deleted file mode 100644 index 5245453717..0000000000 --- a/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/FormActions.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Button from '@tangle-network/ui-components/components/buttons/Button'; -import { ModalFooter } from '@tangle-network/ui-components/components/Modal'; -import { OPERATOR_RPC_URL } from '../../../constants/links'; - -const FormActions = () => { - return ( - - - - - - ); -}; - -export default FormActions; diff --git a/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/index.ts b/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/index.ts deleted file mode 100644 index 081ce7ae2b..0000000000 --- a/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ConfigureBlueprintModal from './ConfigureBlueprintModal'; - -export default ConfigureBlueprintModal; diff --git a/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/types.ts b/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/types.ts deleted file mode 100644 index 9b18b6417b..0000000000 --- a/apps/tangle-cloud/src/pages/blueprints/ConfigureBlueprintModal/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; -import { z } from 'zod'; - -export const blueprintFormSchema = z.object({ - rpcUrl: z - .string() - .url({ message: 'Please enter a valid URL' }) - .or(z.literal('')) - .optional(), -}); - -export type BlueprintFormSchema = z.infer; - -export type BlueprintFormResult = BlueprintFormSchema & { - blueprints: Blueprint[]; -}; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/RegistrationDrawer.tsx b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/RegistrationDrawer.tsx new file mode 100644 index 0000000000..5296b261b8 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/RegistrationDrawer.tsx @@ -0,0 +1,339 @@ +import { useOperator } from '@tangle-network/tangle-shared-ui/data/graphql/useOperators'; +import { + type OperatorRegistration, + useRegisterOperatorTx, +} from '@tangle-network/tangle-shared-ui/data/graphql/useOperatorManagement'; +import { Alert } from '@tangle-network/ui-components/components/Alert'; +import { Button } from '@tangle-network/ui-components/components/buttons'; +import { + Drawer, + DrawerCloseButton, + DrawerContent, + DrawerTitle, +} from '@tangle-network/ui-components/components/Drawer'; +import { Form } from '@tangle-network/ui-components/components/form'; +import SkeletonLoader from '@tangle-network/ui-components/components/SkeletonLoader'; +import SteppedProgress from '@tangle-network/ui-components/components/Progress/SteppedProgress'; +import { Typography } from '@tangle-network/ui-components/typography/Typography'; +import { FC, useCallback, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAccount, useWalletClient } from 'wagmi'; +import { Address, hashMessage, recoverPublicKey } from 'viem'; +import { TangleDAppPagePath } from '../../../types'; +import { TxName } from '../../../constants'; +import useTxNotification from '../../../hooks/useTxNotification'; +import StepNavigation from './components/StepNavigation'; +import ConfigureStep from './steps/ConfigureStep'; +import ReviewStep from './steps/ReviewStep'; +import SelectBlueprintsStep from './steps/SelectBlueprintsStep'; +import { + RegistrationDrawerProps, + RegistrationStep, + STEP_LABELS, + TOTAL_STEPS, +} from './types'; +import { encodeRegistrationInputs } from './registrationInputs'; +import useRegistrationForm from './useRegistrationForm'; + +const RegistrationDrawer: FC = ({ + isOpen, + onOpenChange, + blueprints, + onRemoveBlueprint, + onRegistrationComplete, +}) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const queryClient = useQueryClient(); + const { address: activeAccount } = useAccount(); + const { data: walletClient } = useWalletClient(); + const { data: operator, isLoading: isOperatorLoading } = + useOperator(activeAccount); + const { registerOperator, status: registerTxStatus } = + useRegisterOperatorTx(); + const { notifyProcessing, notifySuccess, notifyError } = useTxNotification(); + + const isActiveOperator = operator?.restakingStatus === 'ACTIVE'; + + const handleClose = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + const { form, step, goNext, goBack, reset, isFirstStep, isLastStep } = + useRegistrationForm({ + blueprints, + onClose: handleClose, + }); + + const handleSubmit = useCallback(async () => { + if (!activeAccount || !walletClient) { + return; + } + + setIsSubmitting(true); + const totalBlueprints = blueprints.length; + + try { + const rpcUrl = form.getValues('rpcUrl') || ''; + + const message = `Tangle Operator Registration\nAddress: ${activeAccount}`; + const signature = await walletClient.signMessage({ message }); + const messageHash = hashMessage(message); + const ecdsaPublicKey = await recoverPublicKey({ + hash: messageHash, + signature, + }); + + let successCount = 0; + + for (let i = 0; i < blueprints.length; i++) { + const blueprint = blueprints[i]; + const blueprintId = BigInt(blueprint.id); + const blueprintConfig = form.getValues( + `blueprintConfigs.${blueprint.id.toString()}`, + ); + + // Show processing notification with step counter for multiple blueprints + notifyProcessing( + TxName.REGISTER_BLUEPRINT, + totalBlueprints > 1 + ? { current: i + 1, total: totalBlueprints } + : undefined, + ); + + const registrationArgs = encodeRegistrationInputs( + blueprint.registrationParams, + blueprintConfig?.params ?? {}, + ); + + const txHash = await registerOperator( + blueprintId, + { ecdsaPublicKey, rpcAddress: rpcUrl }, + registrationArgs, + ); + + if (txHash) { + queryClient.setQueriesData( + { queryKey: ['operator', 'registrations'] }, + (existingRegistrations) => { + const optimisticRegistration: OperatorRegistration = { + blueprintId, + blueprintName: blueprint.name ?? `Blueprint #${blueprint.id}`, + operator: activeAccount as Address, + registeredAt: BigInt(Math.floor(Date.now() / 1000)), + preferences: { + ecdsaPublicKey: ecdsaPublicKey as `0x${string}`, + rpcAddress: rpcUrl, + }, + active: true, + }; + + if (!existingRegistrations) { + return [optimisticRegistration]; + } + + let foundMatchingRegistration = false; + const updatedRegistrations = existingRegistrations.map( + (registration) => { + if (registration.blueprintId !== blueprintId) { + return registration; + } + + foundMatchingRegistration = true; + return { + ...registration, + active: true, + preferences: { + ...registration.preferences, + ecdsaPublicKey: ecdsaPublicKey as `0x${string}`, + rpcAddress: rpcUrl, + }, + registeredAt: optimisticRegistration.registeredAt, + }; + }, + ); + + return foundMatchingRegistration + ? updatedRegistrations + : [optimisticRegistration, ...updatedRegistrations]; + }, + ); + + successCount++; + } + } + + if (successCount > 0) { + const successMessage = + successCount === totalBlueprints + ? `Successfully registered for ${successCount} blueprint${successCount > 1 ? 's' : ''}` + : `Registered for ${successCount} of ${totalBlueprints} blueprints`; + + notifySuccess(TxName.REGISTER_BLUEPRINT, null, successMessage); + reset(); + onRegistrationComplete?.(); + } + } catch (error) { + console.error('Registration failed:', error); + notifyError( + TxName.REGISTER_BLUEPRINT, + error instanceof Error ? error : new Error('Registration failed'), + ); + } finally { + setIsSubmitting(false); + } + }, [ + activeAccount, + walletClient, + form, + blueprints, + registerOperator, + reset, + onRegistrationComplete, + queryClient, + notifyProcessing, + notifySuccess, + notifyError, + ]); + + const handleNext = useCallback(async () => { + await goNext(); + }, [goNext]); + + const renderStepContent = () => { + switch (step) { + case RegistrationStep.SELECT_BLUEPRINTS: + return ( + + ); + case RegistrationStep.CONFIGURE: + return ; + case RegistrationStep.REVIEW: + return ( + + ); + default: + return null; + } + }; + + const renderGatedContent = () => { + if (isOperatorLoading) { + return ( +
+ +
+ ); + } + + if (!isActiveOperator) { + return ( +
+ + +
+ + To become an active operator: + + +
    +
  1. + + Go to the Tangle dApp + +
  2. + +
  3. + + Deposit and delegate your assets + +
  4. + +
  5. + + Return here once your operator status is active + +
  6. +
+ + +
+
+ ); + } + + return ( + <> +
+
+ + Step {step} of {TOTAL_STEPS} + + {STEP_LABELS[step]} +
+ +
+ +
+
+ e.preventDefault()} + > +
{renderStepContent()}
+ +
+ +
+ + +
+ + ); + }; + + return ( + + +
+ + Register as Operator + + +
+ + {renderGatedContent()} +
+
+ ); +}; + +export default RegistrationDrawer; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/components/BlueprintCard.tsx b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/components/BlueprintCard.tsx new file mode 100644 index 0000000000..b96fb7630e --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/components/BlueprintCard.tsx @@ -0,0 +1,60 @@ +import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { Typography } from '@tangle-network/ui-components/typography/Typography'; +import { FC, ReactNode } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type BlueprintCardProps = { + blueprint: Blueprint; + compact?: boolean; + className?: string; + action?: ReactNode; +}; + +const BlueprintCard: FC = ({ + blueprint, + compact = false, + className, + action, +}) => { + const imageSize = compact ? 32 : 48; + + return ( +
+ {blueprint.imgUrl && ( + {blueprint.name} + )} + +
+ + {blueprint.name} + + + + {blueprint.author} + +
+ + {action &&
{action}
} +
+ ); +}; + +export default BlueprintCard; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/components/StepNavigation.tsx b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/components/StepNavigation.tsx new file mode 100644 index 0000000000..a92062a213 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/components/StepNavigation.tsx @@ -0,0 +1,52 @@ +import Button from '@tangle-network/ui-components/components/buttons/Button'; +import { FC } from 'react'; + +type StepNavigationProps = { + isFirstStep: boolean; + isLastStep: boolean; + isNextDisabled?: boolean; + isSubmitting?: boolean; + onBack: () => void; + onNext: () => void; + onSubmit: () => void; +}; + +const StepNavigation: FC = ({ + isFirstStep, + isLastStep, + isNextDisabled = false, + isSubmitting = false, + onBack, + onNext, + onSubmit, +}) => { + return ( +
+ + + {isLastStep ? ( + + ) : ( + + )} +
+ ); +}; + +export default StepNavigation; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/index.ts b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/index.ts new file mode 100644 index 0000000000..e9fc8fa306 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/index.ts @@ -0,0 +1,2 @@ +export { default } from './RegistrationDrawer'; +export * from './types'; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/registrationInputs.ts b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/registrationInputs.ts new file mode 100644 index 0000000000..83044a0fd6 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/registrationInputs.ts @@ -0,0 +1,472 @@ +import { + BlueprintFieldKind, + encodePayload, + type FormFieldValue, + type SchemaField, +} from '@tangle-network/tangle-shared-ui/codec'; +import type { PrimitiveFieldType } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { toHex } from 'viem'; + +const ARRAY_SEGMENT_PATTERN = /^([A-Za-z]+)\[(\d+)\]$/; +const NUMBER_SEGMENT_PATTERN = /^\d+$/; + +type RegistrationParamsMap = Record; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +const isOptionalType = ( + fieldType: PrimitiveFieldType, +): fieldType is { Optional: PrimitiveFieldType } => { + return isRecord(fieldType) && 'Optional' in fieldType; +}; + +const isArrayType = ( + fieldType: PrimitiveFieldType, +): fieldType is { Array: [number, PrimitiveFieldType] } => { + return isRecord(fieldType) && 'Array' in fieldType; +}; + +const isListType = ( + fieldType: PrimitiveFieldType, +): fieldType is { List: PrimitiveFieldType } => { + return isRecord(fieldType) && 'List' in fieldType; +}; + +const isStructType = ( + fieldType: PrimitiveFieldType, +): fieldType is { Struct: PrimitiveFieldType[] } => { + return isRecord(fieldType) && 'Struct' in fieldType; +}; + +const isNumberType = (fieldType: string): boolean => { + return ( + fieldType === 'Uint8' || + fieldType === 'Int8' || + fieldType === 'Uint16' || + fieldType === 'Int16' || + fieldType === 'Uint32' || + fieldType === 'Int32' || + fieldType === 'Uint64' || + fieldType === 'Int64' + ); +}; + +const setNestedValue = ( + currentValue: unknown, + pathSegments: string[], + newValue: unknown, +): unknown => { + if (pathSegments.length === 0) { + return newValue; + } + + const [segment, ...rest] = pathSegments; + + const arraySegmentMatch = ARRAY_SEGMENT_PATTERN.exec(segment); + if (arraySegmentMatch) { + const key = arraySegmentMatch[1]; + const index = Number(arraySegmentMatch[2]); + const nextObject = isRecord(currentValue) ? { ...currentValue } : {}; + const existingArray = Array.isArray(nextObject[key]) ? nextObject[key] : []; + const nextArray = [...existingArray]; + nextArray[index] = setNestedValue(nextArray[index], rest, newValue); + nextObject[key] = nextArray; + return nextObject; + } + + if (NUMBER_SEGMENT_PATTERN.test(segment)) { + const index = Number(segment); + const nextArray = Array.isArray(currentValue) ? [...currentValue] : []; + nextArray[index] = setNestedValue(nextArray[index], rest, newValue); + return nextArray; + } + + const nextObject = isRecord(currentValue) ? { ...currentValue } : {}; + nextObject[segment] = setNestedValue(nextObject[segment], rest, newValue); + return nextObject; +}; + +const primitiveTypeToSchemaField = ( + fieldType: PrimitiveFieldType, + name: string, +): SchemaField => { + if (typeof fieldType === 'string') { + const fieldKindByName: Record = { + Void: BlueprintFieldKind.Void, + Bool: BlueprintFieldKind.Bool, + Uint8: BlueprintFieldKind.Uint8, + Int8: BlueprintFieldKind.Int8, + Uint16: BlueprintFieldKind.Uint16, + Int16: BlueprintFieldKind.Int16, + Uint32: BlueprintFieldKind.Uint32, + Int32: BlueprintFieldKind.Int32, + Uint64: BlueprintFieldKind.Uint64, + Int64: BlueprintFieldKind.Int64, + String: BlueprintFieldKind.String, + Text: BlueprintFieldKind.String, + Bytes: BlueprintFieldKind.Bytes, + AccountId: BlueprintFieldKind.Address, + }; + + const kind = fieldKindByName[fieldType]; + if (kind === undefined) { + throw new Error(`Unsupported registration field type: ${fieldType}`); + } + + return { + kind, + name, + arrayLength: 0, + children: [], + }; + } + + if (isOptionalType(fieldType)) { + return { + kind: BlueprintFieldKind.Optional, + name, + arrayLength: 0, + children: [primitiveTypeToSchemaField(fieldType.Optional, `${name}_opt`)], + }; + } + + if (isArrayType(fieldType)) { + const [length, innerType] = fieldType.Array; + return { + kind: BlueprintFieldKind.Array, + name, + arrayLength: length, + children: [primitiveTypeToSchemaField(innerType, `${name}_item`)], + }; + } + + if (isListType(fieldType)) { + return { + kind: BlueprintFieldKind.List, + name, + arrayLength: 0, + children: [primitiveTypeToSchemaField(fieldType.List, `${name}_item`)], + }; + } + + if (isStructType(fieldType)) { + return { + kind: BlueprintFieldKind.Struct, + name, + arrayLength: 0, + children: fieldType.Struct.map((innerType, index) => + primitiveTypeToSchemaField(innerType, `${name}_${index}`), + ), + }; + } + + throw new Error('Unsupported registration field type'); +}; + +const normalizeValueForField = ( + fieldType: PrimitiveFieldType, + rawValue: unknown, +): FormFieldValue => { + if (typeof fieldType === 'string') { + const normalizedFieldType = fieldType as string; + + if (normalizedFieldType === 'Void') { + return null; + } + + if (normalizedFieldType === 'Bool') { + if (typeof rawValue === 'boolean') { + return rawValue; + } + if (rawValue === 'true') { + return true; + } + if (rawValue === 'false') { + return false; + } + throw new Error('Bool value must be true or false'); + } + + if (isNumberType(normalizedFieldType)) { + if (rawValue === null || rawValue === undefined || rawValue === '') { + throw new Error(`${normalizedFieldType} value is required`); + } + if ( + typeof rawValue !== 'number' && + typeof rawValue !== 'string' && + typeof rawValue !== 'bigint' + ) { + throw new Error(`${normalizedFieldType} value must be numeric`); + } + if (typeof rawValue === 'number') { + return rawValue.toString(); + } + return rawValue as string | bigint; + } + + if (normalizedFieldType === 'AccountId') { + if (typeof rawValue !== 'string' || rawValue.trim().length === 0) { + throw new Error('AccountId value is required'); + } + return rawValue.trim(); + } + + if ( + normalizedFieldType === 'String' || + normalizedFieldType === 'Text' || + normalizedFieldType === 'Bytes' + ) { + if (typeof rawValue !== 'string' || rawValue.trim().length === 0) { + throw new Error(`${normalizedFieldType} value is required`); + } + return rawValue; + } + + throw new Error(`Unsupported value type: ${normalizedFieldType}`); + } + + if (isOptionalType(fieldType)) { + if (!isRecord(rawValue) || !('Optional' in rawValue)) { + return { present: false }; + } + + const optionalValue = rawValue.Optional; + if (optionalValue === null || optionalValue === undefined) { + return { present: false }; + } + + return { + present: true, + inner: normalizeValueForField(fieldType.Optional, optionalValue), + }; + } + + if (isArrayType(fieldType)) { + const [length, innerType] = fieldType.Array; + const inputValues = + isRecord(rawValue) && Array.isArray(rawValue.Array) + ? rawValue.Array + : Array.isArray(rawValue) + ? rawValue + : []; + + if (inputValues.length !== length) { + throw new Error(`Array value must contain ${length} item(s)`); + } + + return Array.from({ length }, (_, index) => + normalizeValueForField(innerType, inputValues[index]), + ); + } + + if (isListType(fieldType)) { + const listFieldValue = + isRecord(rawValue) && 'List' in rawValue ? rawValue.List : rawValue; + if (!Array.isArray(listFieldValue)) { + throw new Error('List value is required'); + } + const inputValues = + Array.isArray(listFieldValue) && + listFieldValue.length === 2 && + Array.isArray(listFieldValue[1]) + ? listFieldValue[1] + : Array.isArray(listFieldValue) + ? listFieldValue + : []; + + return Array.from({ length: inputValues.length }, (_, index) => + normalizeValueForField(fieldType.List, inputValues[index]), + ); + } + + if (isStructType(fieldType)) { + const inputValues = + isRecord(rawValue) && Array.isArray(rawValue.Struct) + ? rawValue.Struct + : Array.isArray(rawValue) + ? rawValue + : []; + + if (inputValues.length !== fieldType.Struct.length) { + throw new Error( + `Struct value must contain ${fieldType.Struct.length} field(s)`, + ); + } + + return fieldType.Struct.map((childFieldType, index) => + normalizeValueForField(childFieldType, inputValues[index]), + ); + } + + throw new Error('Unsupported registration value'); +}; + +const hasRequiredValue = ( + fieldType: PrimitiveFieldType, + rawValue: unknown, +): boolean => { + if (typeof fieldType === 'string') { + const normalizedFieldType = fieldType as string; + + if (normalizedFieldType === 'Void') { + return true; + } + + if (normalizedFieldType === 'Bool') { + return ( + rawValue === 'true' || + rawValue === 'false' || + typeof rawValue === 'boolean' + ); + } + + if (isNumberType(normalizedFieldType)) { + return !(rawValue === undefined || rawValue === null || rawValue === ''); + } + + if ( + normalizedFieldType === 'String' || + normalizedFieldType === 'Text' || + normalizedFieldType === 'Bytes' || + normalizedFieldType === 'AccountId' + ) { + return typeof rawValue === 'string' && rawValue.trim().length > 0; + } + + return rawValue !== undefined && rawValue !== null; + } + + if (isOptionalType(fieldType)) { + if (!isRecord(rawValue) || !('Optional' in rawValue)) { + return true; + } + const optionalValue = rawValue.Optional; + if (optionalValue === null || optionalValue === undefined) { + return true; + } + return hasRequiredValue(fieldType.Optional, optionalValue); + } + + if (isArrayType(fieldType)) { + const [length, innerType] = fieldType.Array; + const inputValues = + isRecord(rawValue) && Array.isArray(rawValue.Array) + ? rawValue.Array + : Array.isArray(rawValue) + ? rawValue + : []; + + if (inputValues.length !== length) { + return false; + } + + for (let index = 0; index < length; index++) { + if (!hasRequiredValue(innerType, inputValues[index])) { + return false; + } + } + + return true; + } + + if (isListType(fieldType)) { + const listFieldValue = + isRecord(rawValue) && 'List' in rawValue ? rawValue.List : rawValue; + if (!Array.isArray(listFieldValue)) { + return false; + } + const inputValues = + Array.isArray(listFieldValue) && + listFieldValue.length === 2 && + Array.isArray(listFieldValue[1]) + ? listFieldValue[1] + : Array.isArray(listFieldValue) + ? listFieldValue + : []; + + for (let index = 0; index < inputValues.length; index++) { + if (!hasRequiredValue(fieldType.List, inputValues[index])) { + return false; + } + } + + return true; + } + + if (isStructType(fieldType)) { + const inputValues = + isRecord(rawValue) && Array.isArray(rawValue.Struct) + ? rawValue.Struct + : Array.isArray(rawValue) + ? rawValue + : []; + + if (inputValues.length !== fieldType.Struct.length) { + return false; + } + + return fieldType.Struct.every((childFieldType, index) => + hasRequiredValue(childFieldType, inputValues[index]), + ); + } + + return false; +}; + +export const upsertRegistrationParamValue = ( + currentParams: RegistrationParamsMap, + paramPath: string, + value: unknown, +): RegistrationParamsMap => { + const [rootParamKey, ...pathSegments] = paramPath.split('.'); + if (!rootParamKey) { + return currentParams; + } + + const nextRootValue = setNestedValue( + currentParams[rootParamKey], + pathSegments, + value, + ); + + return { + ...currentParams, + [rootParamKey]: nextRootValue, + }; +}; + +export const getMissingRegistrationParamIndices = ( + registrationParams: PrimitiveFieldType[], + params: RegistrationParamsMap, +): number[] => { + return registrationParams.reduce((missing, fieldType, index) => { + const rawValue = params[index.toString()]; + if (!hasRequiredValue(fieldType, rawValue)) { + missing.push(index); + } + return missing; + }, []); +}; + +export const encodeRegistrationInputs = ( + registrationParams: PrimitiveFieldType[], + params: RegistrationParamsMap, +): `0x${string}` => { + if (registrationParams.length === 0) { + return '0x'; + } + + const schema = registrationParams.map((fieldType, index) => + primitiveTypeToSchemaField(fieldType, `param_${index}`), + ); + const values = registrationParams.map((fieldType, index) => + normalizeValueForField(fieldType, params[index.toString()]), + ); + const encodedPayload = encodePayload(schema, values); + + return ( + encodedPayload.length > 0 ? toHex(encodedPayload) : '0x' + ) as `0x${string}`; +}; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/ConfigureStep.tsx b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/ConfigureStep.tsx new file mode 100644 index 0000000000..1dc01a3546 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/ConfigureStep.tsx @@ -0,0 +1,174 @@ +import FieldTypeInput from '@tangle-network/tangle-shared-ui/components/PrimitiveFieldTypeInput'; +import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { Input } from '@tangle-network/ui-components'; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@tangle-network/ui-components/components/form'; +import { Typography } from '@tangle-network/ui-components/typography/Typography'; +import { FC, useCallback } from 'react'; +import { UseFormReturn } from 'react-hook-form'; +import { + getMissingRegistrationParamIndices, + upsertRegistrationParamValue, +} from '../registrationInputs'; +import type { RegistrationFormSchema } from '../types'; + +type ConfigureStepProps = { + blueprints: Blueprint[]; + form: UseFormReturn; +}; + +const ConfigureStep: FC = ({ blueprints, form }) => { + const handleParamChange = useCallback( + (blueprintId: string, paramId: string, value: unknown) => { + const currentConfig = form.getValues(`blueprintConfigs.${blueprintId}`); + const currentParams = currentConfig?.params ?? {}; + const nextParams = upsertRegistrationParamValue( + currentParams, + paramId, + value, + ); + + form.setValue(`blueprintConfigs.${blueprintId}`, { + ...currentConfig, + params: nextParams, + }); + }, + [form], + ); + + const blueprintConfigError = form.formState.errors.blueprintConfigs; + + return ( +
+
+ + Configure Settings + + + + Configure your RPC URL and registration parameters for each blueprint. + +
+ + ( + + RPC URL + + + + + + )} + /> + + {typeof blueprintConfigError?.message === 'string' && ( + + {blueprintConfigError.message} + + )} + + {blueprints.map((blueprint) => { + const blueprintId = blueprint.id.toString(); + const hasParams = blueprint.registrationParams.length > 0; + const currentConfig = form.watch(`blueprintConfigs.${blueprintId}`); + const missingParamIndices = getMissingRegistrationParamIndices( + blueprint.registrationParams, + currentConfig?.params ?? {}, + ); + + if (!hasParams) { + return null; + } + + return ( +
+
+ {blueprint.imgUrl && ( + {blueprint.name} + )} + +
+ + {blueprint.name} + + + {blueprint.registrationParams.length} parameter + {blueprint.registrationParams.length > 1 ? 's' : ''} required + + + {missingParamIndices.length > 0 && ( + + Missing required params:{' '} + {missingParamIndices + .map((index) => `#${index + 1}`) + .join(', ')} + + )} +
+
+ +
+ {blueprint.registrationParams.map((param, idx) => { + const paramId = idx.toString(); + const value = currentConfig?.params?.[paramId]; + + return ( + + handleParamChange(blueprintId, id, newValue) + } + tabIndex={idx + 1} + /> + ); + })} +
+
+ ); + })} + + {blueprints.every((bp) => bp.registrationParams.length === 0) && ( + + None of the selected blueprints require registration parameters. + + )} +
+ ); +}; + +export default ConfigureStep; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/ReviewStep.tsx b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/ReviewStep.tsx new file mode 100644 index 0000000000..fdb20456bb --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/ReviewStep.tsx @@ -0,0 +1,119 @@ +import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { Typography } from '@tangle-network/ui-components/typography/Typography'; +import { FC } from 'react'; +import { UseFormReturn } from 'react-hook-form'; +import type { RegistrationFormSchema } from '../types'; + +type ReviewStepProps = { + blueprints: Blueprint[]; + form: UseFormReturn; + isSubmitting: boolean; +}; + +const ReviewStep: FC = ({ + blueprints, + form, + isSubmitting, +}) => { + const rpcUrl = form.watch('rpcUrl'); + const blueprintConfigs = form.watch('blueprintConfigs'); + + return ( +
+
+ + Review Registration + + + + Review your registration details before submitting. + +
+ + {rpcUrl && ( +
+ + RPC URL + + + {rpcUrl} + +
+ )} + +
+ + Blueprints ({blueprints.length}) + + +
+ {blueprints.map((blueprint) => { + const blueprintId = blueprint.id.toString(); + const config = blueprintConfigs[blueprintId]; + const paramsCount = Object.keys(config?.params || {}).length; + + return ( +
+
+ {blueprint.imgUrl && ( + {blueprint.name} + )} + +
+ + {blueprint.name} + + + {blueprint.author} + +
+ + {paramsCount > 0 && ( +
+ + {paramsCount} param{paramsCount > 1 ? 's' : ''} + +
+ )} +
+
+ ); + })} +
+
+ + {isSubmitting && ( + + Registering as operator for {blueprints.length} blueprint + {blueprints.length > 1 ? 's' : ''}... + + )} +
+ ); +}; + +export default ReviewStep; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/SelectBlueprintsStep.tsx b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/SelectBlueprintsStep.tsx new file mode 100644 index 0000000000..f19ada74dc --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/steps/SelectBlueprintsStep.tsx @@ -0,0 +1,60 @@ +import { Close } from '@tangle-network/icons'; +import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import IconButton from '@tangle-network/ui-components/components/buttons/IconButton'; +import { Typography } from '@tangle-network/ui-components/typography/Typography'; +import { FC } from 'react'; +import BlueprintCard from '../components/BlueprintCard'; + +type SelectBlueprintsStepProps = { + blueprints: Blueprint[]; + onRemoveBlueprint?: (blueprintId: string) => void; +}; + +const SelectBlueprintsStep: FC = ({ + blueprints, + onRemoveBlueprint, +}) => { + return ( +
+
+ + Selected Blueprints + + + + You have selected {blueprints.length} blueprint + {blueprints.length > 1 ? 's' : ''} to register as an operator. Review + your selection and proceed to configure settings. + +
+ +
+ {blueprints.map((blueprint) => ( + onRemoveBlueprint(blueprint.id.toString())} + > + + + ) + } + /> + ))} +
+ + + In the next step, you will configure your RPC URL and any required + registration parameters for each blueprint. + +
+ ); +}; + +export default SelectBlueprintsStep; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/types.ts b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/types.ts new file mode 100644 index 0000000000..33cb052396 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/types.ts @@ -0,0 +1,39 @@ +import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { z } from 'zod'; + +export enum RegistrationStep { + SELECT_BLUEPRINTS = 1, + CONFIGURE = 2, + REVIEW = 3, +} + +export const STEP_LABELS: Record = { + [RegistrationStep.SELECT_BLUEPRINTS]: 'Select Blueprints', + [RegistrationStep.CONFIGURE]: 'Configure', + [RegistrationStep.REVIEW]: 'Review', +}; + +export const TOTAL_STEPS = 3; + +export const blueprintConfigSchema = z.object({ + params: z.record(z.string(), z.any()), +}); + +export const registrationFormSchema = z.object({ + rpcUrl: z + .string() + .min(1, 'RPC URL is required') + .url({ message: 'Please enter a valid URL' }), + blueprintConfigs: z.record(z.string(), blueprintConfigSchema), +}); + +export type BlueprintConfig = z.infer; +export type RegistrationFormSchema = z.infer; + +export type RegistrationDrawerProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + blueprints: Blueprint[]; + onRemoveBlueprint?: (blueprintId: string) => void; + onRegistrationComplete?: () => void; +}; diff --git a/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/useRegistrationForm.ts b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/useRegistrationForm.ts new file mode 100644 index 0000000000..46dfe69b96 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/RegistrationDrawer/useRegistrationForm.ts @@ -0,0 +1,158 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { + RegistrationStep, + registrationFormSchema, + RegistrationFormSchema, + TOTAL_STEPS, +} from './types'; +import { getMissingRegistrationParamIndices } from './registrationInputs'; + +type UseRegistrationFormOptions = { + blueprints: Blueprint[]; + onClose?: () => void; +}; + +const useRegistrationForm = ({ + blueprints, + onClose, +}: UseRegistrationFormOptions) => { + const [step, setStep] = useState( + RegistrationStep.SELECT_BLUEPRINTS, + ); + + const defaultBlueprintConfigs = useMemo(() => { + const configs: Record }> = {}; + for (const blueprint of blueprints) { + configs[blueprint.id.toString()] = { + params: {}, + }; + } + return configs; + }, [blueprints]); + + const form = useForm({ + resolver: zodResolver(registrationFormSchema), + defaultValues: { + rpcUrl: '', + blueprintConfigs: defaultBlueprintConfigs, + }, + mode: 'onChange', + }); + + // Sync blueprintConfigs when blueprints change (e.g., when removing a blueprint) + useEffect(() => { + const currentConfigs = form.getValues('blueprintConfigs'); + const updatedConfigs: Record }> = + {}; + + for (const blueprint of blueprints) { + const blueprintId = blueprint.id.toString(); + updatedConfigs[blueprintId] = currentConfigs[blueprintId] || { + params: {}, + }; + } + + form.setValue('blueprintConfigs', updatedConfigs); + }, [blueprints, form]); + + const validateCurrentStep = useCallback(async (): Promise => { + switch (step) { + case RegistrationStep.SELECT_BLUEPRINTS: + return blueprints.length > 0; + + case RegistrationStep.CONFIGURE: { + // RPC URL is required for operator registration + const rpcUrlResult = await form.trigger('rpcUrl'); + if (!rpcUrlResult) { + return false; + } + + const blueprintConfigs = form.getValues('blueprintConfigs'); + const invalidBlueprints: string[] = []; + + for (const blueprint of blueprints) { + const config = blueprintConfigs[blueprint.id.toString()]; + const missingParamIndices = getMissingRegistrationParamIndices( + blueprint.registrationParams, + config?.params ?? {}, + ); + + if (missingParamIndices.length > 0) { + invalidBlueprints.push( + `${blueprint.name}: ${missingParamIndices + .map((index) => `#${index + 1}`) + .join(', ')}`, + ); + } + } + + if (invalidBlueprints.length > 0) { + form.setError('blueprintConfigs', { + type: 'manual', + message: `Complete required params for: ${invalidBlueprints.join('; ')}`, + }); + return false; + } + + form.clearErrors('blueprintConfigs'); + return true; + } + + case RegistrationStep.REVIEW: + return true; + + default: + return false; + } + }, [step, blueprints, form]); + + const goNext = useCallback(async () => { + const isValid = await validateCurrentStep(); + if (!isValid) { + return false; + } + + if (step < TOTAL_STEPS) { + setStep((prev) => (prev + 1) as RegistrationStep); + return true; + } + return false; + }, [step, validateCurrentStep]); + + const goBack = useCallback(() => { + if (step > RegistrationStep.SELECT_BLUEPRINTS) { + setStep((prev) => (prev - 1) as RegistrationStep); + return true; + } + return false; + }, [step]); + + const reset = useCallback(() => { + setStep(RegistrationStep.SELECT_BLUEPRINTS); + form.reset({ + rpcUrl: '', + blueprintConfigs: defaultBlueprintConfigs, + }); + onClose?.(); + }, [form, defaultBlueprintConfigs, onClose]); + + const isFirstStep = step === RegistrationStep.SELECT_BLUEPRINTS; + const isLastStep = step === RegistrationStep.REVIEW; + + return { + form, + step, + setStep, + goNext, + goBack, + reset, + validateCurrentStep, + isFirstStep, + isLastStep, + }; +}; + +export default useRegistrationForm; diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AdvancedOptionsStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AdvancedOptionsStep.tsx new file mode 100644 index 0000000000..5214c25e48 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AdvancedOptionsStep.tsx @@ -0,0 +1,198 @@ +/** + * Advanced deployment options - collapsible section for power users. + */ + +import { FC, useState } from 'react'; +import { Card, Typography, Input } from '@tangle-network/ui-components'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@tangle-network/ui-components/components/select'; +import { ChevronDown, ChevronUp } from '@tangle-network/icons'; +import { BaseDeployStepProps } from './type'; + +interface AdvancedOptionsStepProps extends BaseDeployStepProps { + minimumNativeSecurityRequirement: number; +} + +export const AdvancedOptionsStep: FC = ({ + errors, + setValue, + watch, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + const approvalModel = watch('approvalModel'); + const minApproval = watch('minApproval'); + const maxApproval = watch('maxApproval'); + const operators = watch('operators') ?? []; + + return ( + + + + {isExpanded && ( +
+ {/* Approval Model */} +
+ + Approval Model + + + Configure how job results are approved and aggregated. + + +
+
+ + Model Type + + +
+ +
+ + Min Approvals Required + + setValue('minApproval', Number(v))} + /> + {errors?.minApproval?.message && ( + + {errors.minApproval.message} + + )} +
+ + {approvalModel === 'Dynamic' && ( +
+ + Max Approvals + + setValue('maxApproval', Number(v))} + /> + {errors?.maxApproval?.message && ( + + {errors.maxApproval.message} + + )} +
+ )} +
+ + + {approvalModel === 'Fixed' + ? 'Fixed model requires exactly the minimum number of operator approvals.' + : 'Dynamic model allows between min and max operator approvals.'} + +
+ + {/* Security Deposit Info */} +
+ + Security Information + +
+
+
+ + Selected Operators + + + {operators.length} + +
+
+ + Approval Threshold + + + {minApproval ?? 1} / {operators.length || '-'} + +
+
+ + Operators commit security deposits when joining the service. + These deposits can be slashed for misbehavior. + +
+
+ + {/* Service Lifecycle */} +
+ + Service Lifecycle + + + The service will remain active until the TTL expires or you + terminate it. Operators can leave with proper notice. + +
    +
  • + + Service can be terminated early by the owner + +
  • +
  • + + Unused payments are refunded on termination + +
  • +
  • + + Operator rewards are distributed on job completion + +
  • +
+
+
+ )} +
+ ); +}; + +export default AdvancedOptionsStep; diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx index 6a1641a24a..58ba87d1f5 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/AssetConfigurationStep.tsx @@ -1,12 +1,12 @@ import { Card, Typography, Button } from '@tangle-network/ui-components'; import { Children, FC, useMemo, useState } from 'react'; import { AssetConfigurationStepProps } from './type'; -import assertRestakeAssetId from '@tangle-network/tangle-shared-ui/utils/assertRestakeAssetId'; import { AssetRequirementFormItem } from './components/AssetRequirementFormItem'; -import ErrorMessage from '../../../../../components/ErrorMessage'; -import { RestakeAssetId } from '@tangle-network/tangle-shared-ui/types'; -import { NATIVE_ASSET_ID } from '@tangle-network/tangle-shared-ui/constants/restaking'; -import useAssets from '@tangle-network/tangle-shared-ui/hooks/useAssets'; +import ErrorMessage from '@tangle-network/tangle-shared-ui/components/ErrorMessage'; +import { + useRestakeAssets, + type RestakeAsset, +} from '@tangle-network/tangle-shared-ui/data/graphql'; import { Select, SelectContent, @@ -16,6 +16,7 @@ import { } from '@tangle-network/ui-components/components/select'; import LsTokenIcon from '@tangle-network/tangle-shared-ui/components/LsTokenIcon'; import { TrashIcon } from '@radix-ui/react-icons'; +import type { Address } from 'viem'; export const AssetConfigurationStep: FC = ({ errors, @@ -23,23 +24,20 @@ export const AssetConfigurationStep: FC = ({ watch, setError, clearErrors, - minimumNativeSecurityRequirement, + minimumNativeSecurityRequirement: _minimumNativeSecurityRequirement, }) => { const assets = watch('assets'); - const { result: allAssets } = useAssets(); + const { assets: allAssetsMap } = useRestakeAssets(); const securityCommitments = watch('securityCommitments'); const selectedAssets = useMemo(() => { if (!assets) return []; - return assets.map((asset) => ({ - ...asset, - id: assertRestakeAssetId(asset.id), - })); + return assets; }, [assets]); const onChangeExposurePercent = ( index: number, - assetId: RestakeAssetId, + _assetId: Address, value: number[], ) => { const minExposurePercent = Number(value[0]); @@ -63,11 +61,11 @@ export const AssetConfigurationStep: FC = ({ let errorMsg: string | null = null; - if ( - assetId === NATIVE_ASSET_ID && - minExposurePercent < minimumNativeSecurityRequirement - ) { - errorMsg = `Minimum exposure percent must be greater than or equal to ${minimumNativeSecurityRequirement}`; + // Exposure percent must be at least 1% (contract rejects 0) + if (minExposurePercent < 1) { + errorMsg = 'Minimum exposure percent must be at least 1%'; + } else if (maxExposurePercent < 1) { + errorMsg = 'Maximum exposure percent must be at least 1%'; } else if (minExposurePercent > maxExposurePercent) { errorMsg = 'Minimum exposure percent cannot exceed maximum'; } @@ -83,11 +81,11 @@ export const AssetConfigurationStep: FC = ({ } }; - const [selectedAsset, setSelectedAsset] = useState(''); + const [selectedAsset, setSelectedAsset] = useState
(''); - const addAsset = (assetId: RestakeAssetId) => { - if (!allAssets) return; - const asset = allAssets.get(assetId); + const addAsset = (assetId: Address) => { + if (!allAssetsMap) return; + const asset = allAssetsMap.get(assetId); if (!asset) return; const nextAssets = [ @@ -95,9 +93,10 @@ export const AssetConfigurationStep: FC = ({ { id: asset.id, metadata: { - ...asset.metadata, - deposit: asset.metadata.deposit ?? '', - isFrozen: asset.metadata.isFrozen ?? false, + name: asset.metadata.name, + symbol: asset.metadata.symbol, + decimals: asset.metadata.decimals, + priceInUsd: null, // TODO: Add price feed }, }, ]; @@ -123,16 +122,15 @@ export const AssetConfigurationStep: FC = ({ setValue('securityCommitments', nextSec); }; - const availableAssets = useMemo(() => { - if (!allAssets) return [] as RestakeAssetId[]; + const availableAssets = useMemo(() => { + if (!allAssetsMap) return []; const selectedIds = new Set(assets?.map((a) => a.id)); - return Array.from(allAssets.values()) + return Array.from(allAssetsMap.values()) .filter((asset) => !selectedIds.has(asset.id)) .filter( (asset) => asset.metadata.name && asset.metadata.name.trim() !== '', - ) - .map((a) => a.id); - }, [allAssets, assets]); + ); + }, [allAssetsMap, assets]); return ( @@ -152,8 +150,7 @@ export const AssetConfigurationStep: FC = ({ Block(s)} - onChange={(nextValue) => handleInstanceDurationChange(nextValue)} - /> + +
+ + handleInstanceDurationChange(nextValue) + } + className="flex-1" + /> + + +
+ + Use 0 for perpetual service, or {constraints.min}- + {constraints.max} {durationUnit} + {errors?.instanceDuration && ( {errors.instanceDuration.message} )} @@ -114,7 +171,7 @@ export const BasicInformationStep: FC = ({ } className="flex-grow" inputClassName="placeholder:text-mono-80 dark:placeholder:text-mono-120 h-10 w-full" - placeholder="Enter permitted caller" + placeholder="Enter wallet address (0x...)" autoComplete="off" />