diff --git a/apps/tangle-cloud/src/app/cloudWagmiConfig.ts b/apps/tangle-cloud/src/app/cloudWagmiConfig.ts index 51abdca0f3..238c24bd67 100644 --- a/apps/tangle-cloud/src/app/cloudWagmiConfig.ts +++ b/apps/tangle-cloud/src/app/cloudWagmiConfig.ts @@ -1,4 +1,5 @@ import anvilLocal from '@tangle-network/dapp-config/chains/evm/customChains/anvilLocal'; +import tempoModerato from '@tangle-network/dapp-config/chains/evm/customChains/tempoModerato'; import { getDefaultConfig } from 'connectkit'; import { baseSepolia } from 'viem/chains'; import { createConfig, http } from 'wagmi'; @@ -21,7 +22,7 @@ const ENABLE_FAMILY_WALLET = process.env?.NEXT_PUBLIC_ENABLE_FAMILY_WALLET === 'true')) || false; -const chains = [anvilLocal, baseSepolia] as const; +const chains = [anvilLocal, baseSepolia, tempoModerato] as const; /** * Wallet config matches the Tangle staking dapp — ConnectKit's @@ -42,6 +43,9 @@ export const cloudWagmiConfig = createConfig( timeout: 4_000, }), [baseSepolia.id]: http('https://sepolia.base.org', { timeout: 8_000 }), + [tempoModerato.id]: http('https://rpc.moderato.tempo.xyz', { + timeout: 8_000, + }), }, }), ); diff --git a/apps/tangle-cloud/src/constants/networks.ts b/apps/tangle-cloud/src/constants/networks.ts index b09f7d70e2..ae41067c46 100644 --- a/apps/tangle-cloud/src/constants/networks.ts +++ b/apps/tangle-cloud/src/constants/networks.ts @@ -4,6 +4,7 @@ import { getBrowserLocalServiceUrl } from '@tangle-network/tangle-shared-ui/util export enum NetworkId { ANVIL_LOCAL = 6, BASE_SEPOLIA = 8, + TEMPO_MODERATO = 9, } export type Network = { @@ -51,7 +52,30 @@ export const BASE_SEPOLIA_NETWORK: Network = { isEvm ? `https://sepolia.basescan.org/tx/${txHash}` : null, }; +// Tempo Moderato hosts the live tnt-core 0.19 deployment. Its native gas asset +// is a USD stablecoin (see the viem chain def), but `tokenSymbol` here is the +// staking-token label used by the network switcher, so it stays `tTNT` like the +// other testnets. +export const TEMPO_MODERATO_NETWORK: Network = { + id: NetworkId.TEMPO_MODERATO, + evmChainId: 42431, + name: 'Tempo Moderato', + tokenSymbol: 'tTNT', + nodeType: 'standalone', + wsRpcEndpoints: [], + httpRpcEndpoints: ['https://rpc.moderato.tempo.xyz'], + polkadotJsDashboardUrl: '', + evmExplorerUrl: 'https://explore.tempo.xyz', + createExplorerAccountUrl: (address) => + /^0x[a-fA-F0-9]{40}$/.test(address) + ? `https://explore.tempo.xyz/address/${address}` + : null, + createExplorerTxUrl: (isEvm, txHash) => + isEvm ? `https://explore.tempo.xyz/tx/${txHash}` : null, +}; + export const TANGLE_CLOUD_NETWORKS = [ ANVIL_LOCAL_NETWORK, BASE_SEPOLIA_NETWORK, + TEMPO_MODERATO_NETWORK, ]; diff --git a/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx b/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx index e51cfaa6d6..06362e7f40 100644 --- a/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx +++ b/apps/tangle-cloud/src/pages/services/[id]/OperatorExitPanel.tsx @@ -314,11 +314,10 @@ const OperatorExitPanel: FC = ({ {/* Current Exit Status */} {/* - TODO(v019): `useCanScheduleExit` fail-closes to `{ canExit: false }` on - v019 chains (tnt-core 0.19 removed the `canScheduleExit` view), so the - Schedule-Exit button below is always gated off there and only the - yellow "unavailable" reason renders. Wire getExitStatus-based - eligibility in the hook to re-enable scheduling on v019. + On v019 chains (tnt-core 0.19 removed the `canScheduleExit` view), + `useCanScheduleExit` derives eligibility from `getExitStatus`, so the + Schedule-Exit button below re-enables when the operator is not already + in the exit queue. */} {exitStatus === ExitStatus.None && (
diff --git a/apps/tangle-dapp/src/constants/networks.ts b/apps/tangle-dapp/src/constants/networks.ts index 498386b695..aebdf13568 100644 --- a/apps/tangle-dapp/src/constants/networks.ts +++ b/apps/tangle-dapp/src/constants/networks.ts @@ -18,4 +18,5 @@ export const NETWORK_FEATURE_MAP: Record = { [NetworkId.ANVIL_LOCAL]: [], [NetworkId.BASE]: [], [NetworkId.BASE_SEPOLIA]: [], + [NetworkId.TEMPO_MODERATO]: [], }; diff --git a/libs/dapp-config/src/chains/evm/customChains/tempoModerato.ts b/libs/dapp-config/src/chains/evm/customChains/tempoModerato.ts new file mode 100644 index 0000000000..b9406c004e --- /dev/null +++ b/libs/dapp-config/src/chains/evm/customChains/tempoModerato.ts @@ -0,0 +1,34 @@ +import { EVMChainId } from '@tangle-network/dapp-types'; +import { defineChain } from 'viem'; + +/** + * Tempo Moderato testnet — the chain running the live tnt-core 0.19 deployment. + * + * Unlike every other testnet the dapp wires (which pay gas in ETH), Tempo pays + * gas in a USD stablecoin. viem's `nativeCurrency` still models it as the + * 18-decimal base asset; only the name/symbol change. `decimals: 18` matches the + * other testnet defs so fee/balance formatting stays consistent. + */ +const tempoModerato = defineChain({ + id: EVMChainId.TempoModerato, + name: 'Tempo Moderato', + nativeCurrency: { + name: 'USD', + symbol: 'USD', + decimals: 18, + }, + rpcUrls: { + default: { + http: ['https://rpc.moderato.tempo.xyz'], + }, + }, + blockExplorers: { + default: { + name: 'Tempo Explorer', + url: 'https://explore.tempo.xyz', + }, + }, + testnet: true, +}); + +export default tempoModerato; diff --git a/libs/dapp-config/src/chains/evm/index.tsx b/libs/dapp-config/src/chains/evm/index.tsx index a7fbd9eb4a..d7c0043df5 100644 --- a/libs/dapp-config/src/chains/evm/index.tsx +++ b/libs/dapp-config/src/chains/evm/index.tsx @@ -24,12 +24,14 @@ import { } from 'viem/chains'; import type { ChainConfig } from '../chain-config.interface'; import anvilLocal from './customChains/anvilLocal'; +import tempoModerato from './customChains/tempoModerato'; // Primary chains for development and production export const wagmiChains = [ anvilLocal, // Local development (Anvil on port 8545) base, // Base mainnet baseSepolia, // Base testnet + tempoModerato, // Tempo Moderato testnet (live tnt-core 0.19 deployment) mainnet, // Ethereum mainnet (for native staking) { ...holesky, @@ -200,6 +202,16 @@ export const chainsConfig = { displayName: 'Base Sepolia', } satisfies ChainConfig, + [PresetTypedChainId.TempoModerato]: { + ...tempoModerato, + chainType: ChainType.EVM, + // Tempo hosts Tangle's own tnt-core 0.19 deployment; there is no dedicated + // `tempo` ChainGroup, and `tangle` is the closest semantic bucket. + group: 'tangle', + tag: 'test', + displayName: 'Tempo Moderato', + } satisfies ChainConfig, + [PresetTypedChainId.AnvilLocal]: { ...anvilLocal, chainType: ChainType.EVM, diff --git a/libs/dapp-config/src/contracts.ts b/libs/dapp-config/src/contracts.ts index 853751041d..028b51dc27 100644 --- a/libs/dapp-config/src/contracts.ts +++ b/libs/dapp-config/src/contracts.ts @@ -67,6 +67,38 @@ export const BASE_SEPOLIA_CONTRACTS: ContractAddresses = { blueprintAuditors: '0xf4428bbbfa9207b4b91ca5fe8c1d401fd8b1e8e8', // deployed 2026-05-22 via tnt-core/script/UpgradeFlowAddon.s.sol }; +// Tempo Moderato testnet addresses — live tnt-core 0.19 deployment. +// +// Field mapping (Tempo deploy name -> `ContractAddresses` field): +// tangle -> tangle +// staking/restaking -> multiAssetDelegation +// statusRegistry -> operatorStatusRegistry +// rewardVaults -> rewardVaults +// inflationPool -> inflationPool +// +// The Tempo deploy also exposes contracts that have no field on the shared +// `ContractAddresses` type, so they are intentionally NOT surfaced here (adding +// them would force a field on every other chain's config). Recorded for +// traceability: +// tntToken 0x64fb7ffae82c1682574e9edb4e2f4e377132f671 +// serviceFeeDistributor 0x39d16ff4e0bce0e6b00a4a49be80c2b7ad2fb4b4 +// streamingPaymentManager address(0) — not deployed in this release +// +// Fields with no Tempo address (masterBlueprintServiceManager, credits, +// liquidDelegationFactory, blueprintAuditors) are the zero address; the dapp +// already treats zero-address entries as "not available on this chain". +export const TEMPO_CONTRACTS: ContractAddresses = { + tangle: '0xff137b9c879c47c28ce389e84501925438ab4cda', + multiAssetDelegation: '0x9484d07899b98384f1d66bd5b2659f3ed346f89e', + masterBlueprintServiceManager: '0x0000000000000000000000000000000000000000', // not deployed in this release + operatorStatusRegistry: '0xaedaffd260e21b41cb9926370e32d14bef812b48', + rewardVaults: '0x9a1615fdcaaf2f659d78eb0d72fd3759480af7c4', + inflationPool: '0x3602b76c625464895205466c645feff0d911836f', + credits: '0x0000000000000000000000000000000000000000', // not deployed in this release + liquidDelegationFactory: '0x0000000000000000000000000000000000000000', // not deployed in this release + blueprintAuditors: '0x0000000000000000000000000000000000000000', // pending deployment; dapp falls back to the JSON registry +}; + // Base mainnet addresses (to be updated after deployment) export const BASE_MAINNET_CONTRACTS: ContractAddresses = { tangle: '0x0000000000000000000000000000000000000000', @@ -173,9 +205,14 @@ export const getTntCoreRevisionByChainId = ( chainId: number, ): TntCoreRevision => { switch (chainId) { - // Every currently-wired chain runs pre-0.18 contracts (Base Sepolia is - // pre-#193; local anvil is post-#193 but pre-#194, which for every - // surface the dapp branches on behaves like `legacy`). + // Tempo Moderato runs the live tnt-core 0.19 deployment, so it must select + // the v019 read paths (shorter binary-version / attestation / slash tuples, + // `getExitStatus`-derived exit eligibility). Every other wired chain runs + // pre-0.18 contracts (Base Sepolia is pre-#193; local anvil is post-#193 but + // pre-#194, which for every surface the dapp branches on behaves like + // `legacy`). + case 42431: // Tempo Moderato + return 'v019'; default: return 'legacy'; } @@ -188,6 +225,8 @@ export const getContractsByChainId = (chainId: number): ContractAddresses => { return LOCAL_CONTRACTS; case 84532: // Base Sepolia return BASE_SEPOLIA_CONTRACTS; + case 42431: // Tempo Moderato (live tnt-core 0.19 deployment) + return TEMPO_CONTRACTS; case 8453: // Base Mainnet return BASE_MAINNET_CONTRACTS; case 421614: // Arbitrum Sepolia diff --git a/libs/dapp-types/src/ChainId.ts b/libs/dapp-types/src/ChainId.ts index 8d981a05ed..abf6c3b7e3 100644 --- a/libs/dapp-types/src/ChainId.ts +++ b/libs/dapp-types/src/ChainId.ts @@ -116,6 +116,11 @@ export enum PresetTypedChainId { EVMChainId.ArbitrumSepolia, ), + TempoModerato = calculateTypedChainId( + ChainType.EVM, + EVMChainId.TempoModerato, + ), + AnvilLocal = calculateTypedChainId(ChainType.EVM, EVMChainId.AnvilLocal), BSC = calculateTypedChainId(ChainType.EVM, EVMChainId.BSC), diff --git a/libs/dapp-types/src/EVMChainId.ts b/libs/dapp-types/src/EVMChainId.ts index 9563350b81..6639aacfc0 100644 --- a/libs/dapp-types/src/EVMChainId.ts +++ b/libs/dapp-types/src/EVMChainId.ts @@ -32,6 +32,10 @@ enum EVMChainId { // Arbitrum testnets ArbitrumSepolia = 421614, + // Tempo testnets — tnt-core 0.19 lives here (Tempo pays gas in a USD + // stablecoin, not ETH; see the custom chain def for the nativeCurrency). + TempoModerato = 42431, + // Local development AnvilLocal = 31337, diff --git a/libs/tangle-shared-ui/src/blueprintApps/trustScore.ts b/libs/tangle-shared-ui/src/blueprintApps/trustScore.ts index 74f1dd0c61..816e95b265 100644 --- a/libs/tangle-shared-ui/src/blueprintApps/trustScore.ts +++ b/libs/tangle-shared-ui/src/blueprintApps/trustScore.ts @@ -47,8 +47,9 @@ export interface Attestation { attester: `0x${string}`; reportHash: `0x${string}`; // tnt-core 0.19 dropped `reportUri` from the on-chain `Attestation` struct; - // it is event-sourced from `BinaryVersionAttested`. `null` on v019 chains - // until the indexer wiring lands (follow-up); a string on legacy/v018 chains. + // it is event-sourced from `BinaryVersionAttested`. On v019 `fetchAttestations` + // backfills it from the attest log (joined by attester); `null` only when no + // matching log is found. A string on legacy/v018 chains. reportUri: string | null; kind: AttestationKind; severityFound: number; diff --git a/libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts b/libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts index 68e5864d4e..ea820e8391 100644 --- a/libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts +++ b/libs/tangle-shared-ui/src/data/blueprints/binaryVersion.ts @@ -26,9 +26,11 @@ export interface BinaryVersion { versionId: bigint; sha256Hash: `0x${string}`; // tnt-core 0.19 dropped `binaryUri` from the on-chain `BinaryVersion` struct; - // it is event-sourced from `BinaryVersionPublished`. `null` on v019 chains - // until the indexer wiring lands (follow-up); a non-empty string on - // legacy/v018 chains that still store it in the returned tuple. + // it is event-sourced from `BinaryVersionPublished`. The normalizer leaves it + // `null` (the tuple has no URI slot); on v019 the fetchers in + // `useBinaryVersions` backfill it from the publish log. `null` only when no + // publish event is found. A non-empty string on legacy/v018 chains that still + // store it in the returned tuple. binaryUri: string | null; attestationHash: `0x${string}`; publishedAt: bigint; @@ -68,7 +70,8 @@ export const normalizeBinaryVersion = (raw: { }); // `reportUri` is absent from the 0.19 attestation struct returndata; missing → -// `null` (event-sourced from `BinaryVersionAttested.reportUri` in a follow-up). +// `null` here. On v019 `fetchAttestations` backfills it from the +// `BinaryVersionAttested.reportUri` log, joined by attester address. export const normalizeAttestation = (raw: { attester: Address; reportHash: `0x${string}`; diff --git a/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts index e34c69a235..c81d617c70 100644 --- a/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts +++ b/libs/tangle-shared-ui/src/data/blueprints/useBinaryVersions.ts @@ -8,8 +8,11 @@ */ import { useQuery, useQueries } from '@tanstack/react-query'; -import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; -import type { Address, PublicClient } from 'viem'; +import { + getContractsByChainId, + getTntCoreRevisionByChainId, +} from '@tangle-network/dapp-config/contracts'; +import { parseAbiItem, type Address, type PublicClient } from 'viem'; import { useChainId, usePublicClient } from 'wagmi'; import BinaryUpgradeABI from '../../abi/tangleBinaryUpgrade'; import BlueprintAuditorsABI from '../../abi/blueprintAuditors'; @@ -78,6 +81,94 @@ const auditorRegistryAddressFor = (chainId: number): Address | null => { } }; +// ───────────────────────────────────────────────────────────────────────── +// v019 event-sourced URIs +// +// tnt-core 0.19 dropped the URI blobs from the on-chain STORAGE structs, so the +// struct getters return them as `null` (see `binaryVersion.ts`). The URIs are +// still emitted by the publish/attest events, so on v019 we resolve them from +// logs — scoped to the Tangle contract address plus the indexed blueprintId / +// versionId — exactly the way an off-chain consumer (e.g. blueprint-manager) +// would. Both fetchers below stay robust: if no event is found the URI simply +// stays `null` and nothing throws. +// ───────────────────────────────────────────────────────────────────────── + +// `binaryUri` is a NON-indexed arg, so it comes back decoded in `log.args`. +const BINARY_VERSION_PUBLISHED_EVENT = parseAbiItem( + 'event BinaryVersionPublished(uint64 indexed blueprintId, uint64 indexed versionId, bytes32 sha256Hash, string binaryUri)', +); + +// `reportUri` is a NON-indexed arg. `attestationId` is non-indexed too, so it +// cannot be filtered via `args`; we scope by blueprintId + versionId (+ the +// attester, when known) and match on the decoded fields below. +const BINARY_VERSION_ATTESTED_EVENT = parseAbiItem( + 'event BinaryVersionAttested(uint64 indexed blueprintId, uint64 indexed versionId, uint64 attestationId, address indexed attester, uint8 kind, uint8 severityFound, string reportUri)', +); + +/** + * Resolve the event-sourced `binaryUri` for a single (blueprintId, versionId). + * Returns `null` when no publish log is found (never throws) so callers can + * fall back to the storage tuple's `null` cleanly. + */ +const resolveBinaryUriFromEvent = async ( + publicClient: PublicClient, + tangle: Address, + blueprintId: bigint, + versionId: bigint, +): Promise => { + try { + const logs = await publicClient.getLogs({ + address: tangle, + event: BINARY_VERSION_PUBLISHED_EVENT, + args: { blueprintId, versionId }, + fromBlock: BigInt(0), + toBlock: 'latest', + }); + // A version is published exactly once (append-only), but be defensive and + // take the latest matching log. + const uri = logs.at(-1)?.args.binaryUri; + return uri && uri.length > 0 ? uri : null; + } catch { + return null; + } +}; + +/** + * Resolve the event-sourced `reportUri` for every attestation on a + * (blueprintId, versionId), keyed by attester address. A version can carry + * multiple attestations from different attesters; the map lets the caller join + * each storage-tuple attestation back to its emitted `reportUri`. Returns an + * empty map on any failure (never throws). + */ +const resolveReportUrisFromEvents = async ( + publicClient: PublicClient, + tangle: Address, + blueprintId: bigint, + versionId: bigint, +): Promise> => { + const byAttester = new Map(); + try { + const logs = await publicClient.getLogs({ + address: tangle, + event: BINARY_VERSION_ATTESTED_EVENT, + args: { blueprintId, versionId }, + fromBlock: BigInt(0), + toBlock: 'latest', + }); + for (const log of logs) { + const attester = log.args.attester; + const reportUri = log.args.reportUri; + if (attester && reportUri && reportUri.length > 0) { + // Later logs win — an attester can re-attest a version. + byAttester.set(attester.toLowerCase(), reportUri); + } + } + } catch { + // Swallow — an unresolvable event just leaves `reportUri` null. + } + return byAttester; +}; + // ───────────────────────────────────────────────────────────────────────── // fetchers — exposed so the publisher dialog can refresh after publish // ───────────────────────────────────────────────────────────────────────── @@ -103,6 +194,9 @@ export const fetchBinaryVersions = async ( // parallel via Promise.all is the same pattern fetchBlueprintsOnChain // uses for the blueprint list itself. const readAbi = binaryReadAbiFor(chainId); + // On v019 the storage tuple no longer carries `binaryUri`, so resolve it from + // the `BinaryVersionPublished` event; legacy/v018 chains keep the tuple's URI. + const isV019 = getTntCoreRevisionByChainId(chainId) === 'v019'; const ids = Array.from({ length: Number(count) }, (_, i) => BigInt(i)); const versions = await Promise.all( ids.map(async (versionId) => { @@ -113,7 +207,16 @@ export const fetchBinaryVersions = async ( functionName: 'getBinaryVersion', args: [blueprintId, versionId], })) as Parameters[0]; - return normalizeBinaryVersion(raw); + const version = normalizeBinaryVersion(raw); + if (isV019 && version.binaryUri === null) { + version.binaryUri = await resolveBinaryUriFromEvent( + publicClient, + tangle, + blueprintId, + versionId, + ); + } + return version; } catch { return null; } @@ -139,7 +242,29 @@ export const fetchAttestations = async ( functionName: 'listAttestations', args: [blueprintId, versionId], })) as Array[0]>; - return raw.map(normalizeAttestation); + const attestations = raw.map(normalizeAttestation); + + // On v019 the attestation tuple no longer carries `reportUri`; resolve it + // from the `BinaryVersionAttested` event, joined back by attester address. + // Legacy/v018 chains keep the tuple's URI, so skip the log query there. + if (getTntCoreRevisionByChainId(chainId) === 'v019') { + const reportUris = await resolveReportUrisFromEvents( + publicClient, + tangle, + blueprintId, + versionId, + ); + if (reportUris.size > 0) { + for (const attestation of attestations) { + if (attestation.reportUri === null) { + attestation.reportUri = + reportUris.get(attestation.attester.toLowerCase()) ?? null; + } + } + } + } + + return attestations; } catch { return []; } @@ -219,6 +344,10 @@ export const useBinaryVersions = ( export const useEffectiveBinaryVersion = ( serviceId: bigint | undefined, + // `blueprintId` is optional and only used on v019 to resolve the + // event-sourced `binaryUri` (the tuple drops it). Pass it when the caller + // needs the URI populated; omit it and the URI stays `null` on v019. + blueprintId?: bigint, options?: { enabled?: boolean }, ) => { const chainId = useChainId(); @@ -230,6 +359,7 @@ export const useEffectiveBinaryVersion = ( 'effective-binary-version', chainId, serviceId?.toString() ?? null, + blueprintId?.toString() ?? null, ], queryFn: async (): Promise => { if (!publicClient || serviceId === undefined) return null; @@ -243,7 +373,20 @@ export const useEffectiveBinaryVersion = ( functionName: 'effectiveBinaryVersion', args: [serviceId], })) as Parameters[0]; - return normalizeBinaryVersion(raw); + const version = normalizeBinaryVersion(raw); + if ( + blueprintId !== undefined && + getTntCoreRevisionByChainId(chainId) === 'v019' && + version.binaryUri === null + ) { + version.binaryUri = await resolveBinaryUriFromEvent( + publicClient, + tangle, + blueprintId, + version.versionId, + ); + } + return version; } catch { // Effective version reverts `VersionNotFound` when the blueprint // has zero published binaries. That's not an error to surface — @@ -305,7 +448,22 @@ export const useServiceUpgradeState = ( functionName: 'effectiveBinaryVersion', args: [serviceId], })) as Parameters[0]; - return normalizeBinaryVersion(raw); + const version = normalizeBinaryVersion(raw); + // v019 drops `binaryUri` from the effective-version tuple; resolve + // it from the publish event now that we have the blueprintId. + if ( + blueprintId !== undefined && + getTntCoreRevisionByChainId(chainId) === 'v019' && + version.binaryUri === null + ) { + version.binaryUri = await resolveBinaryUriFromEvent( + publicClient, + tangle, + blueprintId, + version.versionId, + ); + } + return version; } catch { return null; } diff --git a/libs/tangle-shared-ui/src/data/services/canScheduleExit.spec.ts b/libs/tangle-shared-ui/src/data/services/canScheduleExit.spec.ts new file mode 100644 index 0000000000..958cc270dc --- /dev/null +++ b/libs/tangle-shared-ui/src/data/services/canScheduleExit.spec.ts @@ -0,0 +1,63 @@ +/** + * Schedule-exit eligibility mapping coverage. + * + * tnt-core 0.19 removed the boolean `canScheduleExit` view, so on v019 chains + * `useCanScheduleExit` derives eligibility from `getExitStatus` via + * `mapExitStatusToEligibility`. These tests pin the enum -> eligibility mapping: + * only `ExitStatus.None` is schedule-eligible; every already-in-queue / + * completed status gates the Schedule-Exit action off. A slip here would either + * hide the action from operators who can exit, or offer it to operators already + * mid-exit (the chain would then revert their tx). + * + * Imports the pure `canScheduleExit` module directly so the test never pulls + * wagmi / react-query into the jest module graph — same split as the + * `binaryVersion` / `slashProposal` revision specs. + */ + +import { mapExitStatusToEligibility } from './canScheduleExit'; +import { ExitStatus } from './exitStatus'; + +describe('mapExitStatusToEligibility', () => { + it('allows scheduling only when the operator is not in the exit queue (None)', () => { + const result = mapExitStatusToEligibility(ExitStatus.None); + expect(result.canExit).toBe(true); + expect(result.reason).toBe(''); + }); + + it('blocks scheduling when an exit is already Scheduled', () => { + const result = mapExitStatusToEligibility(ExitStatus.Scheduled); + expect(result.canExit).toBe(false); + expect(result.reason).toMatch(/already scheduled/i); + }); + + it('blocks scheduling when the exit is Executable', () => { + const result = mapExitStatusToEligibility(ExitStatus.Executable); + expect(result.canExit).toBe(false); + expect(result.reason).toMatch(/ready to execute/i); + }); + + it('blocks scheduling when the exit is already Completed', () => { + const result = mapExitStatusToEligibility(ExitStatus.Completed); + expect(result.canExit).toBe(false); + expect(result.reason).toMatch(/already exited/i); + }); + + it('fails closed on an unknown status', () => { + const result = mapExitStatusToEligibility(99 as ExitStatus); + expect(result.canExit).toBe(false); + expect(result.reason).toMatch(/could not be determined/i); + }); + + it('is exhaustive over the on-chain enum: exactly None is eligible', () => { + const statuses = [ + ExitStatus.None, + ExitStatus.Scheduled, + ExitStatus.Executable, + ExitStatus.Completed, + ]; + const eligible = statuses.filter( + (s) => mapExitStatusToEligibility(s).canExit, + ); + expect(eligible).toEqual([ExitStatus.None]); + }); +}); diff --git a/libs/tangle-shared-ui/src/data/services/canScheduleExit.ts b/libs/tangle-shared-ui/src/data/services/canScheduleExit.ts new file mode 100644 index 0000000000..6521c99348 --- /dev/null +++ b/libs/tangle-shared-ui/src/data/services/canScheduleExit.ts @@ -0,0 +1,56 @@ +/** + * Pure (wagmi-free) schedule-exit eligibility mapping. + * + * Split out of `useCanScheduleExit.ts` so it can be unit-tested without pulling + * wagmi / react-query (ESM) into the jest module graph — the same split as + * `binaryVersion.ts` / `slashProposal.ts`. The hook module re-exports these, so + * consumers see no change. + * + * tnt-core 0.19 removed the boolean `canScheduleExit(uint64,address)` view but + * keeps `getExitStatus`, which returns the operator's position in the exit + * lifecycle. Schedule-exit eligibility is derivable from it: only an operator + * not already in the queue (`ExitStatus.None`) may schedule a new exit. + */ + +import { ExitStatus } from './exitStatus'; + +export interface CanScheduleExitResult { + canExit: boolean; + reason: string; +} + +/** + * Maps an on-chain `ExitStatus` to schedule-exit eligibility. An operator can + * schedule an exit only when they are not already somewhere in the exit + * lifecycle; any other status means a request already exists (or has completed) + * so the Schedule-Exit action must stay gated off. The chain still enforces + * this at `scheduleExit` time — this read only drives the UI affordance. + */ +export const mapExitStatusToEligibility = ( + status: ExitStatus, +): CanScheduleExitResult => { + switch (status) { + case ExitStatus.None: + return { canExit: true, reason: '' }; + case ExitStatus.Scheduled: + return { + canExit: false, + reason: 'An exit is already scheduled for this service.', + }; + case ExitStatus.Executable: + return { + canExit: false, + reason: 'An exit is already scheduled and ready to execute.', + }; + case ExitStatus.Completed: + return { + canExit: false, + reason: 'You have already exited this service.', + }; + default: + return { + canExit: false, + reason: 'Exit eligibility could not be determined.', + }; + } +}; diff --git a/libs/tangle-shared-ui/src/data/services/exitStatus.ts b/libs/tangle-shared-ui/src/data/services/exitStatus.ts new file mode 100644 index 0000000000..d392108d4e --- /dev/null +++ b/libs/tangle-shared-ui/src/data/services/exitStatus.ts @@ -0,0 +1,37 @@ +/** + * Pure (wagmi-free) `ExitStatus` enum + label helper. + * + * Split out of `useExitStatus.ts` so the enum can be imported by pure, + * unit-testable modules (e.g. `canScheduleExit.ts`) without pulling wagmi / + * react-query into the jest module graph — the same split precedent as + * `binaryVersion.ts`. `useExitStatus.ts` re-exports these, so existing importers + * (and the `data/services` barrel) resolve them unchanged. + * + * The enum order is load-bearing: it is the on-chain uint8 encoding of + * tnt-core `Types.ExitStatus`. + * None = 0 — not in the exit queue + * Scheduled = 1 — exit scheduled, waiting out the queue duration + * Executable = 2 — queue duration elapsed, exit can be executed + * Completed = 3 — exit completed (operator has left) + */ +export enum ExitStatus { + None = 0, + Scheduled = 1, + Executable = 2, + Completed = 3, +} + +export const getExitStatusLabel = (status: ExitStatus): string => { + switch (status) { + case ExitStatus.None: + return 'None'; + case ExitStatus.Scheduled: + return 'Scheduled'; + case ExitStatus.Executable: + return 'Executable'; + case ExitStatus.Completed: + return 'Completed'; + default: + return 'Unknown'; + } +}; diff --git a/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts b/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts index f47bcc5ebf..ec928564f8 100644 --- a/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts +++ b/libs/tangle-shared-ui/src/data/services/useCanScheduleExit.ts @@ -9,12 +9,25 @@ import { getContractsByChainId, getTntCoreRevisionByChainId, } from '@tangle-network/dapp-config/contracts'; +// `ExitStatus` (the canonical tnt-core `Types.ExitStatus` mirror) plus the pure +// eligibility mapping / result type live in wagmi-free modules so they can be +// unit-tested; re-exported below so existing importers are unchanged. +import { ExitStatus } from './exitStatus'; +import { + type CanScheduleExitResult, + mapExitStatusToEligibility, +} from './canScheduleExit'; + +export { + mapExitStatusToEligibility, + type CanScheduleExitResult, +} from './canScheduleExit'; /** * tnt-core 0.19 removed the `canScheduleExit(uint64,address)` view, so it is no * longer present in the synced `TANGLE_ABI`. Keep a local fragment for the - * legacy/v018 direct-read path; v019 chains degrade to a safe default below and - * never reach this ABI. + * legacy/v018 direct-read path; v019 chains derive eligibility from + * `getExitStatus` instead (see `EXIT_STATUS_ABI` and `mapExitStatusToEligibility`). */ const CAN_SCHEDULE_EXIT_ABI = [ { @@ -32,10 +45,27 @@ const CAN_SCHEDULE_EXIT_ABI = [ }, ] as const; -export interface CanScheduleExitResult { - canExit: boolean; - reason: string; -} +/** + * v019 exit-eligibility read. tnt-core 0.19 dropped the boolean + * `canScheduleExit` view but keeps `getExitStatus(uint64,address)`, which + * returns the operator's position in the exit lifecycle. Eligibility to + * SCHEDULE an exit is derivable from it: only an operator not already in the + * queue (`None`) may schedule. + */ +const EXIT_STATUS_ABI = [ + { + type: 'function', + name: 'getExitStatus', + stateMutability: 'view', + inputs: [ + { name: 'serviceId', type: 'uint64', internalType: 'uint64' }, + { name: 'operator', type: 'address', internalType: 'address' }, + ], + outputs: [ + { name: '', type: 'uint8', internalType: 'enum Types.ExitStatus' }, + ], + }, +] as const; export interface UseCanScheduleExitOptions { enabled?: boolean; @@ -76,20 +106,20 @@ export const useCanScheduleExit = ( return { canExit: false, reason: 'Contract not available' }; } - // tnt-core 0.19 removed the `canScheduleExit(uint64,address)` view. There - // is no drop-in on-chain read on v019 (the eligibility is derivable from - // `getExitStatus` + the service's exit config, wired in a follow-up), so - // degrade to a safe, non-blocking default: the chain still enforces exit - // rules at `scheduleExit` time. Fail closed on `canExit` so we never - // render an exit action as available when we cannot verify it. - // TODO(v019): wire getExitStatus-based eligibility so operators on v019 - // chains can schedule exits through the UI (this fail-closed default - // blocks the Schedule-Exit button on every v019 chain until then). + // tnt-core 0.19 removed the `canScheduleExit(uint64,address)` view but + // keeps `getExitStatus`. Derive schedule-exit eligibility from it: an + // operator can schedule only when they are not already in the exit queue + // (`ExitStatus.None`). The chain still enforces the full exit rules at + // `scheduleExit` time — this read just governs the UI affordance. if (getTntCoreRevisionByChainId(chainId) === 'v019') { - return { - canExit: false, - reason: 'Exit eligibility unavailable on this chain', - }; + const status = (await publicClient.readContract({ + address: tangleAddress, + abi: EXIT_STATUS_ABI, + functionName: 'getExitStatus', + args: [serviceId, operator], + })) as ExitStatus; + + return mapExitStatusToEligibility(status); } const result = await publicClient.readContract({ diff --git a/libs/tangle-shared-ui/src/data/services/useExitStatus.ts b/libs/tangle-shared-ui/src/data/services/useExitStatus.ts index d9a8f5bd57..399a45dae4 100644 --- a/libs/tangle-shared-ui/src/data/services/useExitStatus.ts +++ b/libs/tangle-shared-ui/src/data/services/useExitStatus.ts @@ -7,33 +7,18 @@ import { Address, zeroAddress } from 'viem'; import { useChainId, usePublicClient } from 'wagmi'; import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; import TangleABI from '../../abi/tangle'; +// `ExitStatus` + `getExitStatusLabel` live in the pure (wagmi-free) `exitStatus` +// module so they can be imported by unit-tested modules without pulling wagmi. +// `ExitStatus` is used below; both are re-exported so existing importers keep +// resolving them from this module. +import { ExitStatus } from './exitStatus'; -export enum ExitStatus { - None = 0, - Scheduled = 1, - Executable = 2, - Completed = 3, -} +export { ExitStatus, getExitStatusLabel } from './exitStatus'; export interface UseExitStatusOptions { enabled?: boolean; } -export const getExitStatusLabel = (status: ExitStatus): string => { - switch (status) { - case ExitStatus.None: - return 'None'; - case ExitStatus.Scheduled: - return 'Scheduled'; - case ExitStatus.Executable: - return 'Executable'; - case ExitStatus.Completed: - return 'Completed'; - default: - return 'Unknown'; - } -}; - /** * Hook to fetch the exit status for an operator in a service. * diff --git a/libs/ui-components/src/constants/networks.ts b/libs/ui-components/src/constants/networks.ts index cadd6cd8ac..5b55ee7bd5 100644 --- a/libs/ui-components/src/constants/networks.ts +++ b/libs/ui-components/src/constants/networks.ts @@ -38,6 +38,10 @@ export enum NetworkId { ANVIL_LOCAL, BASE, BASE_SEPOLIA, + // Tempo Moderato — live tnt-core 0.19 deployment. Keep this last so the + // numeric position (9) matches the app-local `NetworkId.TEMPO_MODERATO` in + // `apps/tangle-cloud/src/constants/networks.ts`, which mirrors these values. + TEMPO_MODERATO, } export type Network = { @@ -284,6 +288,35 @@ export const BASE_SEPOLIA_NETWORK = { }, } as const satisfies Network; +// Tempo Moderato hosts the live tnt-core 0.19 deployment. Its native gas asset +// is a USD stablecoin, but `tokenSymbol` here is the staking-token label, so it +// stays `tTNT` like the other testnets. +export const TEMPO_MODERATO_NETWORK = { + id: NetworkId.TEMPO_MODERATO, + evmChainId: EVMChainId.TempoModerato, + name: 'Tempo Moderato', + tokenSymbol: 'tTNT', + nodeType: 'standalone', + wsRpcEndpoints: [], + httpRpcEndpoints: ['https://rpc.moderato.tempo.xyz'], + polkadotJsDashboardUrl: '', + evmExplorerUrl: 'https://explore.tempo.xyz', + createExplorerAccountUrl: ( + address: EvmAddress | SubstrateAddress | SolanaAddress, + ) => { + if (isEvmAddress(address)) { + return `https://explore.tempo.xyz/address/${address}`; + } + return null; + }, + createExplorerTxUrl: (isEvm: boolean, txHash: Hex) => { + if (isEvm) { + return `https://explore.tempo.xyz/tx/${txHash}`; + } + return null; + }, +} as const satisfies Network; + export const NETWORK_MAP: Partial> = { [NetworkId.TANGLE_MAINNET]: TANGLE_MAINNET_NETWORK, [NetworkId.TANGLE_TESTNET]: TANGLE_TESTNET_NATIVE_NETWORK, @@ -295,4 +328,5 @@ export const NETWORK_MAP: Partial> = { [NetworkId.ANVIL_LOCAL]: ANVIL_LOCAL_NETWORK, [NetworkId.BASE]: BASE_NETWORK, [NetworkId.BASE_SEPOLIA]: BASE_SEPOLIA_NETWORK, + [NetworkId.TEMPO_MODERATO]: TEMPO_MODERATO_NETWORK, };