@tangle-network/blueprint-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +160 -0
  2. package/package.json +82 -0
  3. package/src/blueprints/registry.ts +107 -0
  4. package/src/components/forms/BlueprintJobForm.tsx +88 -0
  5. package/src/components/forms/FormField.tsx +206 -0
  6. package/src/components/forms/FormSummary.tsx +47 -0
  7. package/src/components/forms/JobExecutionDialog.tsx +203 -0
  8. package/src/components/layout/AppDocument.tsx +48 -0
  9. package/src/components/layout/AppFooter.tsx +38 -0
  10. package/src/components/layout/AppToaster.tsx +33 -0
  11. package/src/components/layout/ChainSwitcher.tsx +71 -0
  12. package/src/components/layout/ThemeToggle.tsx +18 -0
  13. package/src/components/layout/Web3Shell.tsx +28 -0
  14. package/src/components/motion/AnimatedPage.tsx +36 -0
  15. package/src/components/shared/Identicon.tsx +22 -0
  16. package/src/components/shared/TangleLogo.tsx +49 -0
  17. package/src/components/ui/badge.tsx +37 -0
  18. package/src/components/ui/button.tsx +61 -0
  19. package/src/components/ui/card.tsx +34 -0
  20. package/src/components/ui/dialog.tsx +59 -0
  21. package/src/components/ui/input.tsx +24 -0
  22. package/src/components/ui/select.tsx +80 -0
  23. package/src/components/ui/separator.tsx +25 -0
  24. package/src/components/ui/skeleton.tsx +13 -0
  25. package/src/components/ui/table.tsx +49 -0
  26. package/src/components/ui/tabs.tsx +39 -0
  27. package/src/components/ui/textarea.tsx +23 -0
  28. package/src/components/ui/toggle.tsx +35 -0
  29. package/src/components.ts +51 -0
  30. package/src/contracts/abi.ts +259 -0
  31. package/src/contracts/chains.ts +100 -0
  32. package/src/contracts/generic-encoder.ts +69 -0
  33. package/src/contracts/publicClient.ts +55 -0
  34. package/src/env.d.ts +14 -0
  35. package/src/hooks/useAuthenticatedFetch.ts +57 -0
  36. package/src/hooks/useJobForm.ts +78 -0
  37. package/src/hooks/useJobPrice.ts +283 -0
  38. package/src/hooks/useOperators.ts +141 -0
  39. package/src/hooks/useProvisionProgress.ts +125 -0
  40. package/src/hooks/useQuotes.ts +261 -0
  41. package/src/hooks/useServiceValidation.ts +113 -0
  42. package/src/hooks/useSessionAuth.ts +103 -0
  43. package/src/hooks/useSubmitJob.ts +115 -0
  44. package/src/hooks/useThemeValue.ts +6 -0
  45. package/src/index.ts +79 -0
  46. package/src/preset.ts +61 -0
  47. package/src/stores/infra.ts +43 -0
  48. package/src/stores/persistedAtom.ts +67 -0
  49. package/src/stores/session.ts +64 -0
  50. package/src/stores/theme.ts +28 -0
  51. package/src/stores/txHistory.ts +47 -0
  52. package/src/utils/resolveOperatorRpc.ts +20 -0
  53. package/src/utils/web3.ts +21 -0
  54. package/src/utils.ts +6 -0
  55. package/tsconfig.json +21 -0
@@ -0,0 +1,283 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import type { Address } from 'viem';
3
+ import { toHex } from 'viem';
4
+ import { solvePoW, formatCost } from './useQuotes';
5
+
6
+ /**
7
+ * Per-job RFQ hook — fetches price quotes for a specific job before submission.
8
+ *
9
+ * Implements the GetJobPrice protocol:
10
+ * 1. Solve PoW challenge (same as service-level RFQ)
11
+ * 2. POST to operator's /pricing/job-quote endpoint
12
+ * 3. Return signed JobQuoteDetails for submitJobFromQuote()
13
+ *
14
+ * This is the per-job counterpart to useQuotes (which handles service creation).
15
+ * Together they provide full RFQ coverage for the UI.
16
+ */
17
+
18
+ // ── Types ──
19
+
20
+ export interface JobQuote {
21
+ serviceId: bigint;
22
+ jobIndex: number;
23
+ price: bigint;
24
+ timestamp: bigint;
25
+ expiry: bigint;
26
+ signature: `0x${string}`;
27
+ operatorAddress: Address;
28
+ }
29
+
30
+ export interface UseJobPriceResult {
31
+ quote: JobQuote | null;
32
+ isLoading: boolean;
33
+ isSolvingPow: boolean;
34
+ error: string | null;
35
+ formattedPrice: string;
36
+ refetch: () => void;
37
+ }
38
+
39
+ /** Rewrite operator RPC hostname for browser reachability */
40
+ function resolveOperatorRpc(raw: string): string {
41
+ if (typeof window === 'undefined') return raw;
42
+ const withProto = raw.includes('://') ? raw : `http://${raw}`;
43
+ try {
44
+ const url = new URL(withProto);
45
+ const pageHost = window.location.hostname;
46
+ const isNonRoutable =
47
+ url.hostname.endsWith('.local') ||
48
+ !url.hostname.includes('.') ||
49
+ url.hostname === '127.0.0.1' ||
50
+ url.hostname === 'localhost';
51
+ if (isNonRoutable && pageHost !== url.hostname) {
52
+ url.hostname = pageHost;
53
+ }
54
+ return url.toString().replace(/\/$/, '');
55
+ } catch {
56
+ return withProto;
57
+ }
58
+ }
59
+
60
+ // ── Hook ──
61
+
62
+ export function useJobPrice(
63
+ operatorRpcUrl: string | undefined,
64
+ serviceId: bigint,
65
+ jobIndex: number,
66
+ blueprintId: bigint,
67
+ enabled: boolean,
68
+ ): UseJobPriceResult {
69
+ const [quote, setQuote] = useState<JobQuote | null>(null);
70
+ const [isLoading, setIsLoading] = useState(false);
71
+ const [isSolvingPow, setIsSolvingPow] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+ const [fetchKey, setFetchKey] = useState(0);
74
+ const cancelledRef = useRef(false);
75
+
76
+ const refetch = useCallback(() => setFetchKey((k) => k + 1), []);
77
+
78
+ useEffect(() => {
79
+ if (!enabled || !operatorRpcUrl || serviceId === 0n) {
80
+ setQuote(null);
81
+ setError(null);
82
+ return;
83
+ }
84
+
85
+ cancelledRef.current = false;
86
+ setIsLoading(true);
87
+ setError(null);
88
+ setQuote(null);
89
+
90
+ async function fetchJobQuote() {
91
+ try {
92
+ const rpcUrl = resolveOperatorRpc(operatorRpcUrl!);
93
+ const timestamp = BigInt(Math.floor(Date.now() / 1000));
94
+
95
+ // Solve PoW
96
+ setIsSolvingPow(true);
97
+ const { proof } = await solvePoW(blueprintId, timestamp);
98
+ if (cancelledRef.current) return;
99
+ setIsSolvingPow(false);
100
+
101
+ // Fetch job price from operator
102
+ const response = await fetch(`${rpcUrl}/pricing/job-quote`, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({
106
+ service_id: String(serviceId),
107
+ job_index: jobIndex,
108
+ proof_of_work: toHex(proof),
109
+ challenge_timestamp: String(timestamp),
110
+ }),
111
+ signal: AbortSignal.timeout(10_000),
112
+ });
113
+
114
+ if (!response.ok) {
115
+ throw new Error(`HTTP ${response.status}: ${await response.text().catch(() => 'Unknown error')}`);
116
+ }
117
+
118
+ const data = await response.json();
119
+ if (cancelledRef.current) return;
120
+
121
+ setQuote({
122
+ serviceId: BigInt(data.service_id ?? serviceId),
123
+ jobIndex: data.job_index ?? jobIndex,
124
+ price: BigInt(data.price ?? '0'),
125
+ timestamp: BigInt(data.timestamp ?? timestamp),
126
+ expiry: BigInt(data.expiry ?? '0'),
127
+ signature: (data.signature ?? '0x') as `0x${string}`,
128
+ operatorAddress: data.operator as Address,
129
+ });
130
+ } catch (err) {
131
+ if (!cancelledRef.current) {
132
+ setError(err instanceof Error ? err.message : String(err));
133
+ }
134
+ } finally {
135
+ if (!cancelledRef.current) {
136
+ setIsLoading(false);
137
+ setIsSolvingPow(false);
138
+ }
139
+ }
140
+ }
141
+
142
+ fetchJobQuote();
143
+ return () => {
144
+ cancelledRef.current = true;
145
+ };
146
+ }, [operatorRpcUrl, serviceId, jobIndex, blueprintId, enabled, fetchKey]);
147
+
148
+ const formattedPrice = quote ? formatCost(quote.price) : '--';
149
+
150
+ return { quote, isLoading, isSolvingPow, error, formattedPrice, refetch };
151
+ }
152
+
153
+ /**
154
+ * Batch version — fetches job prices for multiple jobs at once.
155
+ * Used by the blueprint job list to show all prices simultaneously.
156
+ */
157
+ export interface JobPriceEntry {
158
+ jobIndex: number;
159
+ jobName: string;
160
+ price: bigint;
161
+ formattedPrice: string;
162
+ mode: 'flat' | 'dynamic' | 'free';
163
+ quote: JobQuote | null;
164
+ error: string | null;
165
+ }
166
+
167
+ export interface UseJobPricesResult {
168
+ prices: JobPriceEntry[];
169
+ isLoading: boolean;
170
+ error: string | null;
171
+ refetch: () => void;
172
+ }
173
+
174
+ export function useJobPrices(
175
+ operatorRpcUrl: string | undefined,
176
+ serviceId: bigint,
177
+ blueprintId: bigint,
178
+ jobIndexes: { index: number; name: string; multiplier: number }[],
179
+ enabled: boolean,
180
+ ): UseJobPricesResult {
181
+ const [prices, setPrices] = useState<JobPriceEntry[]>([]);
182
+ const [isLoading, setIsLoading] = useState(false);
183
+ const [error, setError] = useState<string | null>(null);
184
+ const [fetchKey, setFetchKey] = useState(0);
185
+
186
+ const refetch = useCallback(() => setFetchKey((k) => k + 1), []);
187
+
188
+ useEffect(() => {
189
+ if (!enabled || !operatorRpcUrl || serviceId === 0n || jobIndexes.length === 0) {
190
+ setPrices([]);
191
+ setError(null);
192
+ return;
193
+ }
194
+
195
+ let cancelled = false;
196
+ setIsLoading(true);
197
+ setError(null);
198
+
199
+ async function fetchAllPrices() {
200
+ try {
201
+ const rpcUrl = resolveOperatorRpc(operatorRpcUrl!);
202
+ const timestamp = BigInt(Math.floor(Date.now() / 1000));
203
+ const { proof } = await solvePoW(blueprintId, timestamp);
204
+ if (cancelled) return;
205
+
206
+ // Fetch all job prices in parallel
207
+ const results = await Promise.allSettled(
208
+ jobIndexes.map(async (job) => {
209
+ const response = await fetch(`${rpcUrl}/pricing/job-quote`, {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({
213
+ service_id: String(serviceId),
214
+ job_index: job.index,
215
+ proof_of_work: toHex(proof),
216
+ challenge_timestamp: String(timestamp),
217
+ }),
218
+ signal: AbortSignal.timeout(10_000),
219
+ });
220
+
221
+ if (!response.ok) return null;
222
+ return response.json();
223
+ }),
224
+ );
225
+
226
+ if (cancelled) return;
227
+
228
+ const entries: JobPriceEntry[] = jobIndexes.map((job, i) => {
229
+ const result = results[i];
230
+ if (result.status === 'fulfilled' && result.value) {
231
+ const data = result.value;
232
+ const price = BigInt(data.price ?? '0');
233
+ return {
234
+ jobIndex: job.index,
235
+ jobName: job.name,
236
+ price,
237
+ formattedPrice: formatCost(price),
238
+ mode: (data.mode ?? 'flat') as 'flat' | 'dynamic' | 'free',
239
+ quote: {
240
+ serviceId: BigInt(data.service_id ?? serviceId),
241
+ jobIndex: job.index,
242
+ price,
243
+ timestamp: BigInt(data.timestamp ?? timestamp),
244
+ expiry: BigInt(data.expiry ?? '0'),
245
+ signature: (data.signature ?? '0x') as `0x${string}`,
246
+ operatorAddress: data.operator as Address,
247
+ },
248
+ error: null,
249
+ };
250
+ }
251
+ // Fallback: estimate from multiplier (base rate = 0.001 TNT = 1e15 wei)
252
+ const estimatedPrice = BigInt(job.multiplier) * 1_000_000_000_000_000n;
253
+ return {
254
+ jobIndex: job.index,
255
+ jobName: job.name,
256
+ price: estimatedPrice,
257
+ formattedPrice: `~${formatCost(estimatedPrice)}`,
258
+ mode: 'flat' as const,
259
+ quote: null,
260
+ error: 'No RFQ response — showing estimate',
261
+ };
262
+ });
263
+
264
+ setPrices(entries);
265
+ } catch (err) {
266
+ if (!cancelled) {
267
+ setError(err instanceof Error ? err.message : String(err));
268
+ }
269
+ } finally {
270
+ if (!cancelled) {
271
+ setIsLoading(false);
272
+ }
273
+ }
274
+ }
275
+
276
+ fetchAllPrices();
277
+ return () => {
278
+ cancelled = true;
279
+ };
280
+ }, [operatorRpcUrl, serviceId, blueprintId, jobIndexes, enabled, fetchKey]);
281
+
282
+ return { prices, isLoading, error, refetch };
283
+ }
@@ -0,0 +1,141 @@
1
+ import { useState, useEffect } from 'react';
2
+ import type { Address } from 'viem';
3
+ import { tangleOperatorsAbi } from '../contracts/abi';
4
+ import { publicClient, getAddresses } from '../contracts/publicClient';
5
+
6
+ export interface DiscoveredOperator {
7
+ address: Address;
8
+ ecdsaPublicKey: string;
9
+ rpcAddress: string;
10
+ }
11
+
12
+ /**
13
+ * Discover operators registered for a blueprint by scanning OperatorRegistered
14
+ * events, then verifying each is still active via multicall.
15
+ */
16
+ export function useOperators(blueprintId: bigint) {
17
+ const [operators, setOperators] = useState<DiscoveredOperator[]>([]);
18
+ const [operatorCount, setOperatorCount] = useState<bigint>(0n);
19
+ const [isLoading, setIsLoading] = useState(true);
20
+ const [error, setError] = useState<Error | null>(null);
21
+
22
+ useEffect(() => {
23
+ let cancelled = false;
24
+ const addrs = getAddresses();
25
+
26
+ async function discover() {
27
+ setIsLoading(true);
28
+ setError(null);
29
+
30
+ try {
31
+ // Step 1: Check operator count
32
+ const count = await publicClient.readContract({
33
+ address: addrs.services,
34
+ abi: tangleOperatorsAbi,
35
+ functionName: 'blueprintOperatorCount',
36
+ args: [blueprintId],
37
+ });
38
+ if (cancelled) return;
39
+ setOperatorCount(count as bigint);
40
+
41
+ if ((count as bigint) === 0n) {
42
+ setOperators([]);
43
+ setIsLoading(false);
44
+ return;
45
+ }
46
+
47
+ // Step 2: Fetch OperatorRegistered logs
48
+ const registeredLogs = await publicClient.getLogs({
49
+ address: addrs.services,
50
+ event: {
51
+ type: 'event' as const,
52
+ name: 'OperatorRegistered',
53
+ inputs: [
54
+ { name: 'blueprintId', type: 'uint64', indexed: true },
55
+ { name: 'operator', type: 'address', indexed: true },
56
+ { name: 'ecdsaPublicKey', type: 'bytes', indexed: false },
57
+ { name: 'rpcAddress', type: 'string', indexed: false },
58
+ ],
59
+ },
60
+ args: { blueprintId },
61
+ fromBlock: 0n,
62
+ toBlock: 'latest',
63
+ });
64
+
65
+ if (cancelled) return;
66
+
67
+ // Deduplicate by address (keep latest)
68
+ const byAddress = new Map<Address, DiscoveredOperator>();
69
+ for (const log of registeredLogs) {
70
+ const addr = log.args.operator as Address | undefined;
71
+ if (!addr) continue;
72
+ byAddress.set(addr, {
73
+ address: addr,
74
+ ecdsaPublicKey: (log.args.ecdsaPublicKey as string) ?? '0x',
75
+ rpcAddress: (log.args.rpcAddress as string) ?? '',
76
+ });
77
+ }
78
+
79
+ const candidates = Array.from(byAddress.values());
80
+ if (candidates.length === 0) {
81
+ setOperators([]);
82
+ setIsLoading(false);
83
+ return;
84
+ }
85
+
86
+ // Step 3: Verify each is still registered + fetch current preferences
87
+ const [registrationResults, preferencesResults] = await Promise.all([
88
+ publicClient.multicall({
89
+ contracts: candidates.map((op) => ({
90
+ address: addrs.services,
91
+ abi: tangleOperatorsAbi,
92
+ functionName: 'isOperatorRegistered' as const,
93
+ args: [blueprintId, op.address] as const,
94
+ })),
95
+ }),
96
+ publicClient.multicall({
97
+ contracts: candidates.map((op) => ({
98
+ address: addrs.services,
99
+ abi: tangleOperatorsAbi,
100
+ functionName: 'getOperatorPreferences' as const,
101
+ args: [blueprintId, op.address] as const,
102
+ })),
103
+ }),
104
+ ]);
105
+
106
+ if (cancelled) return;
107
+
108
+ const active: DiscoveredOperator[] = [];
109
+ candidates.forEach((op, i) => {
110
+ if (registrationResults[i]?.result !== true) return;
111
+ const prefs = preferencesResults[i];
112
+ if (prefs?.status === 'success' && prefs.result != null) {
113
+ const result = prefs.result as Record<string, unknown>;
114
+ const ecdsaKey = Array.isArray(result) ? String(result[0] ?? '') : String((result as any).ecdsaPublicKey ?? '');
115
+ const rpcAddr = Array.isArray(result) ? String(result[1] ?? '') : String((result as any).rpcAddress ?? '');
116
+ active.push({
117
+ ...op,
118
+ ecdsaPublicKey: ecdsaKey || op.ecdsaPublicKey,
119
+ rpcAddress: rpcAddr || op.rpcAddress,
120
+ });
121
+ } else {
122
+ active.push(op);
123
+ }
124
+ });
125
+
126
+ setOperators(active);
127
+ } catch (err) {
128
+ if (!cancelled) {
129
+ setError(err instanceof Error ? err : new Error(String(err)));
130
+ }
131
+ } finally {
132
+ if (!cancelled) setIsLoading(false);
133
+ }
134
+ }
135
+
136
+ discover();
137
+ return () => { cancelled = true; };
138
+ }, [blueprintId]);
139
+
140
+ return { operators, isLoading, error, operatorCount };
141
+ }
@@ -0,0 +1,125 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types matching sandbox-runtime/src/provision_progress.rs
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type ProvisionPhase =
8
+ | 'queued'
9
+ | 'image_pull'
10
+ | 'container_create'
11
+ | 'container_start'
12
+ | 'health_check'
13
+ | 'ready'
14
+ | 'failed';
15
+
16
+ export interface ProvisionStatus {
17
+ call_id: number;
18
+ sandbox_id: string | null;
19
+ phase: ProvisionPhase;
20
+ message: string | null;
21
+ started_at: number;
22
+ updated_at: number;
23
+ progress_pct: number;
24
+ sidecar_url: string | null;
25
+ }
26
+
27
+ const PHASE_LABELS: Record<ProvisionPhase, string> = {
28
+ queued: 'Queued',
29
+ image_pull: 'Pulling image',
30
+ container_create: 'Creating container',
31
+ container_start: 'Starting container',
32
+ health_check: 'Health check',
33
+ ready: 'Ready',
34
+ failed: 'Failed',
35
+ };
36
+
37
+ export function getPhaseLabel(phase: ProvisionPhase): string {
38
+ return PHASE_LABELS[phase] ?? phase;
39
+ }
40
+
41
+ export function isTerminalPhase(phase: ProvisionPhase): boolean {
42
+ return phase === 'ready' || phase === 'failed';
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Hook
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const POLL_INTERVAL = 2000;
50
+
51
+ interface UseProvisionProgressOptions {
52
+ callId: number | null;
53
+ apiUrl?: string;
54
+ enabled?: boolean;
55
+ }
56
+
57
+ export function useProvisionProgress({
58
+ callId,
59
+ apiUrl,
60
+ enabled = true,
61
+ }: UseProvisionProgressOptions) {
62
+ const [status, setStatus] = useState<ProvisionStatus | null>(null);
63
+ const [error, setError] = useState<string | null>(null);
64
+ const [isPolling, setIsPolling] = useState(false);
65
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
66
+
67
+ const baseUrl = apiUrl ?? import.meta.env.VITE_OPERATOR_API_URL ?? 'http://localhost:9090';
68
+
69
+ const fetchProgress = useCallback(async () => {
70
+ if (callId == null) return;
71
+
72
+ try {
73
+ const res = await fetch(`${baseUrl}/api/provisions/${callId}`);
74
+ if (res.status === 404) {
75
+ // Not yet tracked — keep polling
76
+ return;
77
+ }
78
+ if (!res.ok) {
79
+ throw new Error(`HTTP ${res.status}`);
80
+ }
81
+ const data: ProvisionStatus = await res.json();
82
+ setStatus(data);
83
+ setError(null);
84
+
85
+ // Stop polling on terminal phase
86
+ if (isTerminalPhase(data.phase) && intervalRef.current) {
87
+ clearInterval(intervalRef.current);
88
+ intervalRef.current = null;
89
+ setIsPolling(false);
90
+ }
91
+ } catch (err) {
92
+ setError(err instanceof Error ? err.message : 'Failed to fetch provision status');
93
+ }
94
+ }, [callId, baseUrl]);
95
+
96
+ useEffect(() => {
97
+ if (!enabled || callId == null) return;
98
+
99
+ setIsPolling(true);
100
+ fetchProgress(); // Initial fetch
101
+
102
+ intervalRef.current = setInterval(fetchProgress, POLL_INTERVAL);
103
+
104
+ return () => {
105
+ if (intervalRef.current) {
106
+ clearInterval(intervalRef.current);
107
+ intervalRef.current = null;
108
+ }
109
+ setIsPolling(false);
110
+ };
111
+ }, [enabled, callId, fetchProgress]);
112
+
113
+ return {
114
+ status,
115
+ error,
116
+ isPolling,
117
+ phase: status?.phase ?? null,
118
+ progressPct: status?.progress_pct ?? 0,
119
+ sandboxId: status?.sandbox_id ?? null,
120
+ sidecarUrl: status?.sidecar_url ?? null,
121
+ message: status?.message ?? null,
122
+ isReady: status?.phase === 'ready',
123
+ isFailed: status?.phase === 'failed',
124
+ };
125
+ }