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..d388e5e90c 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,15 @@ 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 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/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/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..31f4dd3f7b 100644 --- a/apps/tangle-cloud/src/app/app.tsx +++ b/apps/tangle-cloud/src/app/app.tsx @@ -5,6 +5,7 @@ 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'; @@ -12,6 +13,14 @@ 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'; +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 +44,15 @@ const App: FC = () => { } /> + + + + } + /> + { } /> + + + + } + /> + + + + + } + /> + { } /> + + + + } + /> + + + + + } + /> + + + + + } + /> + } /> 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/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/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/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/data/operators/useOperatorStats.ts b/apps/tangle-cloud/src/data/operators/useOperatorStats.ts new file mode 100644 index 0000000000..10cd72d1df --- /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; + 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 ?? 0, + 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..5ad92bdba4 --- /dev/null +++ b/apps/tangle-cloud/src/data/operators/useUserStats.ts @@ -0,0 +1,65 @@ +/** + * EVM hook for fetching user statistics from the Envio indexer. + */ + +import { useMemo } from 'react'; +import { Address } from 'viem'; +import { + useServicesByOwner, + usePendingServiceRequests, +} 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); + + const result = useMemo(() => { + const activeServices = + ownedServices?.filter((s) => s.status === 'ACTIVE') ?? []; + const allDeployed = ownedServices ?? []; + const pendingCount = pendingRequests?.length ?? 0; + + return { + runningServices: activeServices.length, + deployedServices: allDeployed.length, + pendingServices: pendingCount, + consumedServices: 0, // TODO: Query services where user is a permitted caller + }; + }, [ownedServices, pendingRequests]); + + const refetch = async () => { + await Promise.all([refetchOwned(), refetchPending()]); + }; + + return { + result, + isLoading: isLoadingOwned || isLoadingPending, + 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/useOperatorRegisterTx.ts b/apps/tangle-cloud/src/data/services/useOperatorRegisterTx.ts new file mode 100644 index 0000000000..f2f6e9fc90 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useOperatorRegisterTx.ts @@ -0,0 +1,76 @@ +/** + * EVM hook for registering an operator for a blueprint. + * @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 OperatorRegisterParams { + blueprintId: bigint; + preferences: { + key: string; + value: string; + }[]; + registrationArgs: unknown[]; +} + +export interface OperatorBatchRegisterParams { + blueprintIds: bigint[]; + ecdsaPublicKey: `0x${string}`; + rpcAddress: string; + registrationArgs: (PrimitiveField[] | undefined)[]; + amounts: string[]; +} + +/** + * Hook for registering as an operator for a blueprint. + */ +export const useOperatorRegisterTx = () => { + const execute = async (_params: OperatorRegisterParams): Promise => { + console.warn( + 'useOperatorRegisterTx 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, + }; +}; + +/** + * 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, + }; +}; + +export default useOperatorRegisterTx; 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..74f7de68f4 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useServiceApproveTx.ts @@ -0,0 +1,37 @@ +/** + * EVM hook for approving a service request via the Tangle contract. + * @deprecated TODO: Implement using proper Tangle contract ABI + */ + +import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; + +export interface ServiceApproveParams { + requestId: bigint; + restakingPercent: number; +} + +/** + * Hook for approving a service request. + */ +export const useServiceApproveTx = () => { + const execute = async (_params: ServiceApproveParams): Promise => { + console.warn( + 'useServiceApproveTx 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, + }; +}; + +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..a0116b3d51 --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useServiceRejectTx.ts @@ -0,0 +1,36 @@ +/** + * EVM hook for rejecting a service request via the Tangle contract. + * @deprecated TODO: Implement using proper Tangle contract ABI + */ + +import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; + +export interface ServiceRejectParams { + requestId: bigint; +} + +/** + * Hook for rejecting a service request. + */ +export const useServiceRejectTx = () => { + const execute = async (_params: ServiceRejectParams): Promise => { + console.warn( + 'useServiceRejectTx 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, + }; +}; + +export default useServiceRejectTx; 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..41b04d02ae --- /dev/null +++ b/apps/tangle-cloud/src/data/services/useServiceTerminateTx.ts @@ -0,0 +1,36 @@ +/** + * EVM hook for terminating a service via the Tangle contract. + * @deprecated TODO: Implement using proper Tangle contract ABI + */ + +import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useContractWrite'; + +export interface ServiceTerminateParams { + serviceId: bigint; +} + +/** + * Hook for terminating a service. + */ +export const useServiceTerminateTx = () => { + const execute = async (_params: ServiceTerminateParams): Promise => { + console.warn( + 'useServiceTerminateTx 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, + }; +}; + +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/pages/blueprints/BlueprintListing.tsx b/apps/tangle-cloud/src/pages/blueprints/BlueprintListing.tsx index 69f29b158f..9ba8df0cd5 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; +} & UseAllBlueprintsReturn; const BlueprintListing: FC = ({ rowSelection, 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..f77ce63a5e 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 { + 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,12 +61,8 @@ 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}`; - } else if (minExposurePercent > maxExposurePercent) { + // TODO: Handle native asset validation when native asset support is added + if (minExposurePercent > maxExposurePercent) { errorMsg = 'Minimum exposure percent cannot exceed maximum'; } @@ -83,11 +77,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 +89,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 +118,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 +146,7 @@ export const AssetConfigurationStep: FC = ({ { - const asset = assets?.get(assertRestakeAssetId(assetId)); + const asset = assets?.get(assetId as `0x${string}`); if (asset) { onSelectAsset(asset); } diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/AssetRequirementFormItem.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/AssetRequirementFormItem.tsx index 900b3e5792..3cf1f127bc 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/AssetRequirementFormItem.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/AssetRequirementFormItem.tsx @@ -1,4 +1,3 @@ -import { RestakeAssetId } from '@tangle-network/tangle-shared-ui/types'; import { Typography, Label, Slider } from '@tangle-network/ui-components'; import { FC } from 'react'; import LsTokenIcon from '@tangle-network/tangle-shared-ui/components/LsTokenIcon'; @@ -6,9 +5,10 @@ import ErrorMessage from '../../../../../../components/ErrorMessage'; import cx from 'classnames'; import { AssetSchema } from '../../../../../../utils/validations/deployBlueprint'; import { LabelClassName } from '../type'; +import type { Address } from 'viem'; type BaseAssetRequirementFormItemProps = { - assetId?: RestakeAssetId; + assetId?: Address; assetMetadata?: AssetSchema | null; minExposurePercent?: number; maxExposurePercent?: number; diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx index 6f696df01a..6151980c08 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/components/OperatorTable.tsx @@ -1,16 +1,11 @@ -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; import { Avatar, - ExternalLinkIcon, CheckBox, EMPTY_VALUE_PLACEHOLDER, fuzzyFilter, KeyValueWithButton, - formatDisplayAmount, - AmountFormatStyle, Table, Typography, - toSubstrateAddress, } from '@tangle-network/ui-components'; import { FC } from 'react'; import { OperatorSelectionTable } from '../type'; @@ -22,14 +17,33 @@ import { useReactTable, getPaginationRowModel, TableOptions, + SortingFn, } from '@tanstack/react-table'; -import { sortByAddressOrIdentity } from '@tangle-network/tangle-shared-ui/components/tables/utils'; import TableCellWrapper from '@tangle-network/tangle-shared-ui/components/tables/TableCellWrapper'; import VaultsDropdown from '@tangle-network/tangle-shared-ui/components/tables/Operators/VaultsDropdown'; import { TableVariant } from '@tangle-network/ui-components/components/Table/types'; -import { BN } from 'bn.js'; +import { formatUnits } from 'viem'; import { TANGLE_TOKEN_DECIMALS } from '@tangle-network/dapp-config'; +// Local sort function for EVM address-based operator table +const sortByAddressOrIdentity: SortingFn = ( + rowA, + rowB, +) => { + const { address: addressA, identityName: identityNameA } = rowA.original; + const { address: addressB, identityName: identityNameB } = rowB.original; + + if (identityNameA && identityNameB) { + return identityNameA.localeCompare(identityNameB); + } else if (identityNameA) { + return -1; + } else if (identityNameB) { + return 1; + } else { + return addressA.localeCompare(addressB); + } +}; + const COLUMN_HELPER = createColumnHelper(); type Props = Omit< @@ -40,23 +54,12 @@ type Props = Omit< }; export const OperatorTable: FC = ({ tableData, ...tableProps }) => { - const activeNetwork = useNetworkStore().network; - const columns = [ COLUMN_HELPER.accessor('address', { header: () => 'Identity', - sortingFn: sortByAddressOrIdentity(), + sortingFn: sortByAddressOrIdentity, cell: (props) => { - const { address: rawAddress, identityName: identity } = - props.row.original; - - const substrateAddress = toSubstrateAddress( - rawAddress, - activeNetwork.ss58Prefix, - ); - - const accountUrl = - activeNetwork.createExplorerAccountUrl(substrateAddress); + const { address, identityName: identity } = props.row.original; return ( @@ -72,23 +75,16 @@ export const OperatorTable: FC = ({ tableData, ...tableProps }) => {
- {accountUrl && ( - - )}
@@ -99,16 +95,13 @@ export const OperatorTable: FC = ({ tableData, ...tableProps }) => { header: () => 'Self-Bonded', cell: (props) => { const value = props.row.original.selfBondedAmount; + const formatted = formatUnits(value, TANGLE_TOKEN_DECIMALS); + const displayValue = Number(formatted).toLocaleString(undefined, { + maximumFractionDigits: 2, + }); return ( - - {formatDisplayAmount( - new BN(value.toString()), - TANGLE_TOKEN_DECIMALS, - AmountFormatStyle.SHORT, - )}{' '} - TNT - + {displayValue} TNT ); }, @@ -120,7 +113,6 @@ export const OperatorTable: FC = ({ tableData, ...tableProps }) => { }), COLUMN_HELPER.accessor('instanceCount', { header: () => 'Instances', - sortingFn: sortByAddressOrIdentity(), cell: (props) => { return ( diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/type.ts b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/type.ts index be02314024..eb60569b4c 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/type.ts +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/type.ts @@ -7,8 +7,8 @@ import { UseFormWatch, } from 'react-hook-form'; import { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; -import { SubstrateAddress } from '@tangle-network/ui-components/types/address'; import { VaultToken } from '@tangle-network/tangle-shared-ui/types'; +import { Address } from 'viem'; export const LabelClassName = 'text-mono-200 dark:text-mono-0 font-medium'; @@ -36,7 +36,7 @@ export type RequestArgsConfigurationStepProps = BaseDeployStepProps; export type PaymentStepProps = BaseDeployStepProps; export type OperatorSelectionTable = { - address: SubstrateAddress; + address: Address; identityName?: string; vaultTokensInUsd?: number; selfBondedAmount: bigint; diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx index 08634ad0b1..3c094939ae 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/page.tsx @@ -8,39 +8,38 @@ import { useForm } from 'react-hook-form'; import { DeployBlueprintSchema, deployBlueprintSchema, - formatServiceRegisterData, } from '../../../../utils/validations/deployBlueprint'; import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate } from 'react-router'; -import useBlueprintDetails from '@tangle-network/tangle-shared-ui/data/restake/useBlueprintDetails'; +import { + useBlueprintDetails, + useServiceRequestTx, + encodeServiceConfig, + type ServiceRequestParams, +} from '@tangle-network/tangle-shared-ui/data/graphql'; import { Deployment } from './DeploySteps/Deployment'; import { twMerge } from 'tailwind-merge'; -import { ArrowRightIcon } from '@radix-ui/react-icons'; import ErrorMessage from '../../../../components/ErrorMessage'; import { z } from 'zod'; -import useServiceRequestTx from '../../../../data/services/useServicesRequestTx'; -import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx'; import { PagePath } from '../../../../types'; -import { getApiPromise } from '@tangle-network/tangle-shared-ui/utils/polkadot/api'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; import useParamWithSchema from '@tangle-network/tangle-shared-ui/hooks/useParamWithSchema'; +import { zeroAddress } from 'viem'; const DeployPage: FC = () => { const id = useParamWithSchema('id', z.coerce.bigint()); const navigate = useNavigate(); - const wsRpcEndpoints = useNetworkStore( - (store) => store.network2?.wsRpcEndpoints, - ); - const { result: blueprintResult, isLoading: isBlueprintLoading, error: blueprintError, } = useBlueprintDetails(id); - const { execute: serviceRegisterTx, status: serviceRegisterStatus } = - useServiceRequestTx(); + const { + execute: serviceRequestTx, + isSuccess: serviceRequestSuccess, + isPending: serviceRequestPending, + } = useServiceRequestTx(); const { watch, @@ -77,19 +76,18 @@ const DeployPage: FC = () => { ); // Automatically navigate to the blueprint details page when the service - // register transaction is complete. + // request transaction is complete. useEffect(() => { - if (id !== undefined && serviceRegisterStatus === TxStatus.COMPLETE) { + if (id !== undefined && serviceRequestSuccess) { navigate(`${PagePath.BLUEPRINTS_DETAILS}`.replace(':id', id.toString())); } - }, [serviceRegisterStatus, id, navigate]); + }, [serviceRequestSuccess, id, navigate]); if (isBlueprintLoading) { return ; } else if (blueprintError) { return ; } else if (blueprintResult === null) { - // TODO: Show 404 page return null; } @@ -98,17 +96,30 @@ const DeployPage: FC = () => { clearErrors(); const validatedData = deployBlueprintSchema.parse(watch()); - const serviceRegisterData = formatServiceRegisterData( - blueprintResult.details, - validatedData, - ); - if (serviceRegisterTx && wsRpcEndpoints) { - const apiPromise = await getApiPromise(wsRpcEndpoints); - await serviceRegisterTx({ - ...serviceRegisterData, - apiPromise: apiPromise, - }); - } + // Format the service request data for the Tangle contract + // Operators are already Address[] from the schema + const operators = validatedData.operators ?? []; + const permittedCallers = validatedData.permittedCallers ?? []; + const ttl = BigInt(validatedData.instanceDuration ?? 0); + + // Get payment configuration + const paymentToken = validatedData.paymentAsset?.id ?? zeroAddress; + const paymentAmount = BigInt(validatedData.paymentAmount ?? '0'); + + // Encode service configuration from request args + const config = encodeServiceConfig(validatedData.requestArgs ?? []); + + const params: ServiceRequestParams = { + blueprintId: id ?? BigInt(0), + operators, + config, + permittedCallers, + ttl, + paymentToken, + paymentAmount, + }; + + await serviceRequestTx(params); } catch (error) { if (error instanceof z.ZodError) { error.errors.forEach((err) => { @@ -142,11 +153,7 @@ const DeployPage: FC = () => { Error(s) on validation. Please check the form and try again. )} -
diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/page.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/page.tsx index 2d04941db3..4255e44ba3 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/page.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/page.tsx @@ -2,7 +2,7 @@ import BlueprintHeader from '@tangle-network/tangle-shared-ui/components/blueprints/BlueprintHeader'; import OperatorsTable from '@tangle-network/tangle-shared-ui/components/tables/Operators'; -import useBlueprintDetails from '@tangle-network/tangle-shared-ui/data/restake/useBlueprintDetails'; +import { useBlueprintDetails } from '@tangle-network/tangle-shared-ui/data/graphql'; import { ErrorFallback } from '@tangle-network/ui-components/components/ErrorFallback'; import SkeletonLoader from '@tangle-network/ui-components/components/SkeletonLoader'; import { Typography } from '@tangle-network/ui-components/typography/Typography'; @@ -15,10 +15,9 @@ import type { BlueprintFormResult } from '../ConfigureBlueprintModal/types'; import { SessionStorageKey } from '../../../constants'; import useOperatorInfo from '@tangle-network/tangle-shared-ui/hooks/useOperatorInfo'; -import useNetworkStore from '@tangle-network/tangle-shared-ui/context/useNetworkStore'; -import { toSubstrateAddress } from '@tangle-network/ui-components'; import useParamWithSchema from '@tangle-network/tangle-shared-ui/hooks/useParamWithSchema'; import { z } from 'zod'; +import { useAccount } from 'wagmi'; const RestakeOperatorAction: FC> = ({ children, @@ -34,24 +33,20 @@ const Page = () => { const navigate = useNavigate(); const id = useParamWithSchema('id', z.coerce.bigint()); const { result, isLoading, error } = useBlueprintDetails(id); - const { isOperator, operatorAddress } = useOperatorInfo(); - const ss58Prefix = useNetworkStore((store) => store.network.ss58Prefix); + const { isOperator } = useOperatorInfo(); + const { address: userAddress } = useAccount(); const [isBlueprintModalOpen, setIsBlueprintModalOpen] = useState(false); + // Check if the current user is registered as an operator for this blueprint const isRegistered = useMemo(() => { - if (operatorAddress === null || result?.operators === undefined) { + if (!userAddress || result?.operators === undefined) { return false; } return result.operators.some((operator) => { - try { - const opAddr = toSubstrateAddress(operator.address, ss58Prefix); - return opAddr === operatorAddress; - } catch { - return false; - } + return operator.address.toLowerCase() === userAddress.toLowerCase(); }); - }, [operatorAddress, result?.operators, ss58Prefix]); + }, [userAddress, result?.operators]); if (isLoading) { return ( @@ -111,7 +106,7 @@ const Page = () => {
diff --git a/apps/tangle-cloud/src/pages/blueprints/create/page.tsx b/apps/tangle-cloud/src/pages/blueprints/create/page.tsx new file mode 100644 index 0000000000..367199cba0 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/create/page.tsx @@ -0,0 +1,1082 @@ +/** + * Blueprint creation wizard - multi-step form for creating new blueprints. + */ + +import { FC, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import { useAccount } from 'wagmi'; +import { + Button, + Card, + CardVariant, + Typography, + Input, +} from '@tangle-network/ui-components'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@tangle-network/ui-components/components/select'; +import { ArrowLeft, CheckboxCircleFill } from '@tangle-network/icons'; +import { + useCreateBlueprintTx, + type BlueprintDefinition, +} from '@tangle-network/tangle-shared-ui/data/graphql'; +import ErrorMessage from '../../../components/ErrorMessage'; +import { PagePath } from '../../../types'; +import { zeroAddress, type Address, toHex } from 'viem'; + +// Wizard steps +enum Step { + BasicInfo = 0, + Configuration = 1, + Jobs = 2, + Sources = 3, + Review = 4, +} + +const STEP_LABELS = [ + 'Basic Info', + 'Configuration', + 'Jobs', + 'Sources', + 'Review', +]; + +// Simplified job form +interface JobForm { + name: string; + description: string; +} + +// Simplified source form +interface SourceForm { + kind: 'Container' | 'Wasm' | 'Native'; + registry?: string; + image?: string; + tag?: string; + artifactUri?: string; + entrypoint?: string; +} + +// Form state (simplified for user input) +interface FormState { + // Metadata + name: string; + description: string; + author: string; + category: string; + codeRepository: string; + logo: string; + website: string; + license: string; + metadataUri: string; + manager: string; + // Configuration + membership: 'Fixed' | 'Dynamic'; + pricing: 'PayOnce' | 'Subscription' | 'EventDriven'; + minOperators: number; + maxOperators: number; + subscriptionRate: string; + subscriptionInterval: string; + eventRate: string; + operatorBond: string; + // Jobs + jobs: JobForm[]; + // Schemas + registrationSchema: string; + requestSchema: string; + // Sources + sources: SourceForm[]; +} + +const initialFormState: FormState = { + name: '', + description: '', + author: '', + category: '', + codeRepository: '', + logo: '', + website: '', + license: 'MIT', + metadataUri: '', + manager: '', + membership: 'Fixed', + pricing: 'PayOnce', + minOperators: 1, + maxOperators: 10, + subscriptionRate: '0', + subscriptionInterval: '2592000', + eventRate: '0', + operatorBond: '0', + jobs: [], + registrationSchema: '{}', + requestSchema: '{}', + sources: [], +}; + +// Convert form to ABI-compatible definition +const formToDefinition = ( + form: FormState, + address: Address, +): BlueprintDefinition => { + const membershipNum = form.membership === 'Fixed' ? 0 : 1; + const pricingNum = + form.pricing === 'PayOnce' ? 0 : form.pricing === 'Subscription' ? 1 : 2; + + // Convert schemas to hex bytes + const registrationBytes = toHex( + new TextEncoder().encode(form.registrationSchema), + ); + const requestBytes = toHex(new TextEncoder().encode(form.requestSchema)); + + // Convert jobs + const jobs = form.jobs.map((job) => ({ + name: job.name, + description: job.description, + metadataUri: '', + paramsSchema: '0x' as `0x${string}`, + resultSchema: '0x' as `0x${string}`, + })); + + // Convert sources + const sources = form.sources.map((source) => { + const kindNum = + source.kind === 'Container' ? 0 : source.kind === 'Wasm' ? 1 : 2; + return { + kind: kindNum, + container: { + registry: source.registry || '', + image: source.image || '', + tag: source.tag || 'latest', + }, + wasm: { + runtime: 0, + fetcher: 0, + artifactUri: source.artifactUri || '', + entrypoint: source.entrypoint || '', + }, + native: { + fetcher: 0, + artifactUri: source.artifactUri || '', + entrypoint: source.entrypoint || '', + }, + testing: { + cargoPackage: '', + cargoBin: '', + basePath: '', + }, + binaries: [], + }; + }); + + return { + metadataUri: form.metadataUri, + manager: (form.manager || zeroAddress) as Address, + masterManagerRevision: 0, + hasConfig: true, + config: { + membership: membershipNum, + pricing: pricingNum, + minOperators: form.minOperators, + maxOperators: form.maxOperators, + subscriptionRate: BigInt(form.subscriptionRate), + subscriptionInterval: BigInt(form.subscriptionInterval), + eventRate: BigInt(form.eventRate), + operatorBond: BigInt(form.operatorBond), + }, + metadata: { + name: form.name, + description: form.description, + author: form.author || address, + category: form.category, + codeRepository: form.codeRepository, + logo: form.logo, + website: form.website, + license: form.license, + profilingData: '', + }, + jobs, + registrationSchema: registrationBytes, + requestSchema: requestBytes, + sources, + // 0 = Fixed, 1 = Dynamic - include the selected membership model + supportedMemberships: form.membership === 'Fixed' ? [0] : [1], + }; +}; + +const CreateBlueprintPage: FC = () => { + const navigate = useNavigate(); + const { address, isConnected } = useAccount(); + const { createBlueprint, status, error, reset } = useCreateBlueprintTx(); + + const [step, setStep] = useState(Step.BasicInfo); + const [form, setForm] = useState(initialFormState); + const [validationError, setValidationError] = useState(null); + + const isSubmitting = status === 'pending'; + const isSuccess = status === 'success'; + + const updateForm = useCallback( + (key: K, value: FormState[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + setValidationError(null); + }, + [], + ); + + const validateStep = useCallback(() => { + switch (step) { + case Step.BasicInfo: + if (!form.name.trim()) { + setValidationError('Blueprint name is required'); + return false; + } + break; + case Step.Configuration: + if (form.minOperators < 1) { + setValidationError('Minimum operators must be at least 1'); + return false; + } + if (form.maxOperators > 0 && form.maxOperators < form.minOperators) { + setValidationError( + 'Maximum operators must be greater than minimum operators', + ); + return false; + } + break; + case Step.Jobs: + // Jobs are optional + break; + case Step.Sources: + if (form.sources.length === 0) { + setValidationError('At least one source is required'); + return false; + } + break; + } + setValidationError(null); + return true; + }, [step, form]); + + const handleNext = useCallback(() => { + if (!validateStep()) return; + setStep((prev) => Math.min(prev + 1, Step.Review)); + }, [validateStep]); + + const handleBack = useCallback(() => { + setStep((prev) => Math.max(prev - 1, Step.BasicInfo)); + }, []); + + const handleSubmit = useCallback(async () => { + if (!validateStep() || !address) return; + + const definition = formToDefinition(form, address); + await createBlueprint(definition); + }, [form, address, createBlueprint, validateStep]); + + // Add job helper + const addJob = useCallback(() => { + setForm((prev) => ({ + ...prev, + jobs: [...prev.jobs, { name: '', description: '' }], + })); + }, []); + + // Update job helper + const updateJob = useCallback((index: number, updates: Partial) => { + setForm((prev) => ({ + ...prev, + jobs: prev.jobs.map((job, i) => + i === index ? { ...job, ...updates } : job, + ), + })); + }, []); + + // Remove job helper + const removeJob = useCallback((index: number) => { + setForm((prev) => ({ + ...prev, + jobs: prev.jobs.filter((_, i) => i !== index), + })); + }, []); + + // Add source helper + const addSource = useCallback(() => { + setForm((prev) => ({ + ...prev, + sources: [...prev.sources, { kind: 'Container' }], + })); + }, []); + + // Update source helper + const updateSource = useCallback( + (index: number, updates: Partial) => { + setForm((prev) => ({ + ...prev, + sources: prev.sources.map((source, i) => + i === index ? { ...source, ...updates } : source, + ), + })); + }, + [], + ); + + // Remove source helper + const removeSource = useCallback((index: number) => { + setForm((prev) => ({ + ...prev, + sources: prev.sources.filter((_, i) => i !== index), + })); + }, []); + + if (!isConnected) { + return ( +
+ Connect Wallet + + Please connect your wallet to create a blueprint. + +
+ ); + } + + if (isSuccess) { + return ( +
+ + Blueprint Created! + + Your blueprint has been created successfully. + +
+ + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+ + Create Blueprint + + + Define a new blueprint for operators to register with. + +
+
+ + {/* Step Indicator */} +
+ {STEP_LABELS.map((label, index) => ( +
+
+ {index < step ? '✓' : index + 1} +
+ + {label} + + {index < STEP_LABELS.length - 1 && ( +
+ )} +
+ ))} +
+ + {/* Form Content */} + + {step === Step.BasicInfo && ( + + )} + {step === Step.Configuration && ( + + )} + {step === Step.Jobs && ( + + )} + {step === Step.Sources && ( + + )} + {step === Step.Review && } + + {/* Errors */} + {validationError && ( +
+ {validationError} +
+ )} + {error && ( +
+ {error.message} +
+ )} +
+ + {/* Navigation Buttons */} +
+ + + {step === Step.Review ? ( + + ) : ( + + )} +
+
+ ); +}; + +// Step Components +interface StepProps { + form: FormState; + updateForm: (key: K, value: FormState[K]) => void; +} + +const BasicInfoStep: FC = ({ form, updateForm }) => ( +
+ + Basic Information + + +
+
+ + Blueprint Name * + + updateForm('name', v)} + placeholder="Enter blueprint name" + isControlled + /> +
+ +
+ + Author + + updateForm('author', v)} + placeholder="Your name or address" + isControlled + /> +
+
+ +
+ + Description + +