@tangle-network/blueprint-ui 0.1.0 → 0.1.2

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 (48) hide show
  1. package/README.md +47 -11
  2. package/dist/BlueprintHostPanel-6iVEh-f1.d.ts +39 -0
  3. package/dist/chunk-37ADATBT.js +55 -0
  4. package/dist/chunk-37ADATBT.js.map +1 -0
  5. package/dist/chunk-A6PJT5YQ.js +1180 -0
  6. package/dist/chunk-A6PJT5YQ.js.map +1 -0
  7. package/dist/chunk-GD3AZEJL.js +327 -0
  8. package/dist/chunk-GD3AZEJL.js.map +1 -0
  9. package/dist/components.d.ts +179 -0
  10. package/dist/components.js +1127 -0
  11. package/dist/components.js.map +1 -0
  12. package/dist/host.d.ts +96 -0
  13. package/dist/host.js +39 -0
  14. package/dist/host.js.map +1 -0
  15. package/dist/index.d.ts +8470 -0
  16. package/dist/index.js +841 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/preset.d.ts +60 -0
  19. package/dist/preset.js +7 -0
  20. package/dist/preset.js.map +1 -0
  21. package/dist/registry-JhwB9BPD.d.ts +87 -0
  22. package/dist/styles.css +559 -0
  23. package/package.json +42 -13
  24. package/src/components/layout/AppDocument.tsx +1 -1
  25. package/src/components/layout/ChainSwitcher.tsx +27 -10
  26. package/src/components/layout/Web3Shell.tsx +81 -6
  27. package/src/components/web3/ConnectWalletCta.tsx +21 -0
  28. package/src/components.ts +6 -0
  29. package/src/contracts/abi.ts +10 -1
  30. package/src/contracts/chains.ts +23 -10
  31. package/src/contracts/publicClient.ts +52 -10
  32. package/src/hooks/useOperators.ts +203 -96
  33. package/src/hooks/useProvisionProgress.ts +2 -1
  34. package/src/hooks/useQuotes.ts +69 -14
  35. package/src/hooks/useSessionAuth.ts +2 -1
  36. package/src/hooks/useSidecarAuth.ts +173 -0
  37. package/src/hooks/useWagmiSidecarAuth.ts +11 -0
  38. package/src/hooks/useWalletEthBalance.ts +68 -0
  39. package/src/host/components/BlueprintHostHero.tsx +91 -0
  40. package/src/host/components/BlueprintHostPanel.tsx +24 -0
  41. package/src/host/index.ts +42 -0
  42. package/src/host/resolver.ts +204 -0
  43. package/src/host/types.ts +111 -0
  44. package/src/index.ts +48 -2
  45. package/src/stores/infra.ts +3 -2
  46. package/src/styles.css +128 -0
  47. package/src/utils/env.ts +22 -0
  48. package/src/utils/web3.ts +9 -3
@@ -9,9 +9,208 @@ export interface DiscoveredOperator {
9
9
  rpcAddress: string;
10
10
  }
11
11
 
12
+ interface OperatorDiscoveryClient {
13
+ readContract(args: Record<string, unknown>): Promise<unknown>;
14
+ getLogs(args: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
15
+ multicall(args: Record<string, unknown>): Promise<Array<Record<string, unknown>>>;
16
+ }
17
+
18
+ interface OperatorDiscoveryResult {
19
+ operators: DiscoveredOperator[];
20
+ operatorCount: bigint;
21
+ }
22
+
23
+ function readPreferenceValue(
24
+ result: unknown,
25
+ field: 'ecdsaPublicKey' | 'rpcAddress',
26
+ ): string {
27
+ if (Array.isArray(result)) {
28
+ return String(result[field === 'ecdsaPublicKey' ? 0 : 1] ?? '');
29
+ }
30
+ if (result && typeof result === 'object') {
31
+ return String((result as Record<string, unknown>)[field] ?? '');
32
+ }
33
+ return '';
34
+ }
35
+
36
+ async function verifyCandidatesWithMulticall(
37
+ client: OperatorDiscoveryClient,
38
+ servicesAddress: Address,
39
+ blueprintId: bigint,
40
+ candidates: DiscoveredOperator[],
41
+ ): Promise<DiscoveredOperator[] | null> {
42
+ const [registrationResults, preferencesResults] = await Promise.all([
43
+ client.multicall({
44
+ contracts: candidates.map((op) => ({
45
+ address: servicesAddress,
46
+ abi: tangleOperatorsAbi,
47
+ functionName: 'isOperatorRegistered' as const,
48
+ args: [blueprintId, op.address] as const,
49
+ })),
50
+ }),
51
+ client.multicall({
52
+ contracts: candidates.map((op) => ({
53
+ address: servicesAddress,
54
+ abi: tangleOperatorsAbi,
55
+ functionName: 'getOperatorPreferences' as const,
56
+ args: [blueprintId, op.address] as const,
57
+ })),
58
+ }),
59
+ ]);
60
+
61
+ const hadRegistrationFailure = registrationResults.some((result) => result?.status === 'failure');
62
+ const hadPreferenceFailure = preferencesResults.some((result) => result?.status === 'failure');
63
+
64
+ const active: DiscoveredOperator[] = [];
65
+ candidates.forEach((op, i) => {
66
+ if (registrationResults[i]?.result !== true) return;
67
+ const prefs = preferencesResults[i];
68
+ if (prefs?.status === 'success' && prefs.result != null) {
69
+ active.push({
70
+ ...op,
71
+ ecdsaPublicKey: readPreferenceValue(prefs.result, 'ecdsaPublicKey') || op.ecdsaPublicKey,
72
+ rpcAddress: readPreferenceValue(prefs.result, 'rpcAddress') || op.rpcAddress,
73
+ });
74
+ return;
75
+ }
76
+ active.push(op);
77
+ });
78
+
79
+ if (active.length === 0 && (hadRegistrationFailure || hadPreferenceFailure)) {
80
+ return null;
81
+ }
82
+
83
+ return active;
84
+ }
85
+
86
+ async function verifyCandidatesDirectly(
87
+ client: OperatorDiscoveryClient,
88
+ servicesAddress: Address,
89
+ blueprintId: bigint,
90
+ candidates: DiscoveredOperator[],
91
+ ): Promise<DiscoveredOperator[]> {
92
+ const results = await Promise.allSettled(
93
+ candidates.map(async (op) => {
94
+ const registration = await client.readContract({
95
+ address: servicesAddress,
96
+ abi: tangleOperatorsAbi,
97
+ functionName: 'isOperatorRegistered',
98
+ args: [blueprintId, op.address],
99
+ });
100
+
101
+ if (registration !== true) {
102
+ return null;
103
+ }
104
+
105
+ try {
106
+ const preferences = await client.readContract({
107
+ address: servicesAddress,
108
+ abi: tangleOperatorsAbi,
109
+ functionName: 'getOperatorPreferences',
110
+ args: [blueprintId, op.address],
111
+ });
112
+
113
+ return {
114
+ ...op,
115
+ ecdsaPublicKey: readPreferenceValue(preferences, 'ecdsaPublicKey') || op.ecdsaPublicKey,
116
+ rpcAddress: readPreferenceValue(preferences, 'rpcAddress') || op.rpcAddress,
117
+ };
118
+ } catch {
119
+ return op;
120
+ }
121
+ }),
122
+ );
123
+
124
+ const active = results
125
+ .filter((result): result is PromiseFulfilledResult<DiscoveredOperator | null> => result.status === 'fulfilled')
126
+ .map((result) => result.value)
127
+ .filter((op): op is DiscoveredOperator => op != null);
128
+
129
+ if (active.length > 0) {
130
+ return active;
131
+ }
132
+
133
+ const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
134
+ if (failure) {
135
+ throw failure.reason instanceof Error ? failure.reason : new Error(String(failure.reason));
136
+ }
137
+
138
+ return [];
139
+ }
140
+
141
+ /**
142
+ * Shared discovery logic for operator lookup.
143
+ * Tries multicall first for efficiency, then falls back to direct reads when
144
+ * multicall is unavailable in local/dev environments.
145
+ */
146
+ export async function discoverOperatorsWithClient(
147
+ client: OperatorDiscoveryClient,
148
+ servicesAddress: Address,
149
+ blueprintId: bigint,
150
+ ): Promise<OperatorDiscoveryResult> {
151
+ const count = await client.readContract({
152
+ address: servicesAddress,
153
+ abi: tangleOperatorsAbi,
154
+ functionName: 'blueprintOperatorCount',
155
+ args: [blueprintId],
156
+ });
157
+ const operatorCount = count as bigint;
158
+
159
+ if (operatorCount === 0n) {
160
+ return { operators: [], operatorCount };
161
+ }
162
+
163
+ const registeredLogs = await client.getLogs({
164
+ address: servicesAddress,
165
+ event: {
166
+ type: 'event' as const,
167
+ name: 'OperatorRegistered',
168
+ inputs: [
169
+ { name: 'blueprintId', type: 'uint64', indexed: true },
170
+ { name: 'operator', type: 'address', indexed: true },
171
+ { name: 'ecdsaPublicKey', type: 'bytes', indexed: false },
172
+ { name: 'rpcAddress', type: 'string', indexed: false },
173
+ ],
174
+ },
175
+ args: { blueprintId },
176
+ fromBlock: 0n,
177
+ toBlock: 'latest',
178
+ });
179
+
180
+ const byAddress = new Map<Address, DiscoveredOperator>();
181
+ for (const log of registeredLogs) {
182
+ const args = (log.args ?? {}) as Record<string, unknown>;
183
+ const addr = args.operator as Address | undefined;
184
+ if (!addr) continue;
185
+ byAddress.set(addr, {
186
+ address: addr,
187
+ ecdsaPublicKey: String(args.ecdsaPublicKey ?? '0x'),
188
+ rpcAddress: String(args.rpcAddress ?? ''),
189
+ });
190
+ }
191
+
192
+ const candidates = Array.from(byAddress.values());
193
+ if (candidates.length === 0) {
194
+ return { operators: [], operatorCount };
195
+ }
196
+
197
+ try {
198
+ const active = await verifyCandidatesWithMulticall(client, servicesAddress, blueprintId, candidates);
199
+ if (active != null) {
200
+ return { operators: active, operatorCount };
201
+ }
202
+ } catch {
203
+ // Fall through to direct reads below.
204
+ }
205
+
206
+ const active = await verifyCandidatesDirectly(client, servicesAddress, blueprintId, candidates);
207
+ return { operators: active, operatorCount };
208
+ }
209
+
12
210
  /**
13
211
  * Discover operators registered for a blueprint by scanning OperatorRegistered
14
- * events, then verifying each is still active via multicall.
212
+ * events, then verifying each is still active. Falls back to direct reads when
213
+ * multicall is unavailable.
15
214
  */
16
215
  export function useOperators(blueprintId: bigint) {
17
216
  const [operators, setOperators] = useState<DiscoveredOperator[]>([]);
@@ -28,102 +227,10 @@ export function useOperators(blueprintId: bigint) {
28
227
  setError(null);
29
228
 
30
229
  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
-
230
+ const result = await discoverOperatorsWithClient(publicClient, addrs.services, blueprintId);
106
231
  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);
232
+ setOperatorCount(result.operatorCount);
233
+ setOperators(result.operators);
127
234
  } catch (err) {
128
235
  if (!cancelled) {
129
236
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { getEnvVar } from '../utils/env';
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Types matching sandbox-runtime/src/provision_progress.rs
@@ -64,7 +65,7 @@ export function useProvisionProgress({
64
65
  const [isPolling, setIsPolling] = useState(false);
65
66
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
66
67
 
67
- const baseUrl = apiUrl ?? import.meta.env.VITE_OPERATOR_API_URL ?? 'http://localhost:9090';
68
+ const baseUrl = apiUrl ?? getEnvVar('VITE_OPERATOR_API_URL') ?? 'http://localhost:9090';
68
69
 
69
70
  const fetchProgress = useCallback(async () => {
70
71
  if (callId == null) return;
@@ -18,6 +18,16 @@ import { resolveOperatorRpc } from '../utils/resolveOperatorRpc';
18
18
 
19
19
  // ── Types ──
20
20
 
21
+ type SecurityCommitment = {
22
+ asset: { kind: number; token: Address };
23
+ exposureBps: number;
24
+ };
25
+
26
+ type ResourceCommitment = {
27
+ kind: number;
28
+ count: bigint;
29
+ };
30
+
21
31
  export interface OperatorQuote {
22
32
  operator: Address;
23
33
  totalCost: bigint;
@@ -28,12 +38,13 @@ export interface OperatorQuote {
28
38
  totalCost: bigint;
29
39
  timestamp: bigint;
30
40
  expiry: bigint;
31
- securityCommitments: readonly {
32
- asset: { kind: number; token: Address };
33
- exposureBps: number;
34
- }[];
41
+ confidentiality: number;
42
+ securityCommitments: readonly SecurityCommitment[];
43
+ resourceCommitments: readonly ResourceCommitment[];
35
44
  };
36
45
  costRate: number;
46
+ teeAttested?: boolean;
47
+ teeProvider?: string;
37
48
  }
38
49
 
39
50
  export interface UseQuotesResult {
@@ -49,6 +60,20 @@ export interface UseQuotesResult {
49
60
 
50
61
  const POW_DIFFICULTY = 20;
51
62
  const WEI_PER_TNT = 1_000_000_000_000_000_000; // 10^18
63
+ const RESOURCE_KIND_TO_ID = {
64
+ CPU: 0,
65
+ MemoryMB: 1,
66
+ StorageMB: 2,
67
+ NetworkEgressMB: 3,
68
+ NetworkIngressMB: 4,
69
+ GPU: 5,
70
+ } as const;
71
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address;
72
+ const DEFAULT_RESOURCE_REQUIREMENTS = [
73
+ { kind: 'CPU', count: 1 },
74
+ { kind: 'MemoryMB', count: 1024 },
75
+ { kind: 'StorageMB', count: 10240 },
76
+ ] as const;
52
77
 
53
78
  function sha256(data: Uint8Array): Uint8Array {
54
79
  return viemSha256(data, 'bytes');
@@ -114,6 +139,35 @@ export function formatCost(totalCost: bigint): string {
114
139
  return `${tnt.toLocaleString(undefined, { maximumFractionDigits: 2 })} TNT`;
115
140
  }
116
141
 
142
+ function quoteConfidentiality(requireTee: boolean): number {
143
+ return requireTee ? 1 : 0;
144
+ }
145
+
146
+ function resourceKindToId(kind: string): number {
147
+ const mapped = RESOURCE_KIND_TO_ID[kind as keyof typeof RESOURCE_KIND_TO_ID];
148
+ if (mapped === undefined) {
149
+ throw new Error(`Unsupported resource kind in quote: ${kind}`);
150
+ }
151
+ return mapped;
152
+ }
153
+
154
+ function mapJsonSecurityCommitment(sc: any): SecurityCommitment {
155
+ return {
156
+ asset: {
157
+ kind: sc.asset?.kind ?? 0,
158
+ token: (sc.asset?.token ?? ZERO_ADDRESS) as Address,
159
+ },
160
+ exposureBps: sc.exposure_bps ?? 0,
161
+ };
162
+ }
163
+
164
+ function mapJsonResourceCommitment(resource: any): ResourceCommitment {
165
+ return {
166
+ kind: resourceKindToId(String(resource.kind ?? 'CPU')),
167
+ count: BigInt(resource.count ?? 0),
168
+ };
169
+ }
170
+
117
171
  // ── Hook ──
118
172
 
119
173
  export function useQuotes(
@@ -121,6 +175,7 @@ export function useQuotes(
121
175
  blueprintId: bigint,
122
176
  ttlBlocks: bigint,
123
177
  enabled: boolean,
178
+ requireTee = false,
124
179
  ): UseQuotesResult {
125
180
  const [quotes, setQuotes] = useState<OperatorQuote[]>([]);
126
181
  const [isLoading, setIsLoading] = useState(false);
@@ -168,6 +223,7 @@ export function useQuotes(
168
223
  ttlBlocks,
169
224
  proofOfWork: proof,
170
225
  challengeTimestamp: timestamp,
226
+ requireTee,
171
227
  });
172
228
 
173
229
  if (!response) throw new Error('No quote returned from operator');
@@ -194,7 +250,7 @@ export function useQuotes(
194
250
  return () => {
195
251
  cancelled = true;
196
252
  };
197
- }, [operators, blueprintId, ttlBlocks, enabled, fetchKey]);
253
+ }, [operators, blueprintId, ttlBlocks, enabled, fetchKey, requireTee]);
198
254
 
199
255
  const totalCost = quotes.reduce((sum, q) => sum + q.totalCost, 0n);
200
256
 
@@ -213,6 +269,7 @@ async function fetchPriceFromOperator(
213
269
  ttlBlocks: bigint;
214
270
  proofOfWork: Uint8Array;
215
271
  challengeTimestamp: bigint;
272
+ requireTee: boolean;
216
273
  },
217
274
  ): Promise<OperatorQuote | null> {
218
275
  // Try JSON endpoint (simpler, no protobuf dependency required)
@@ -225,11 +282,8 @@ async function fetchPriceFromOperator(
225
282
  ttl_blocks: String(params.ttlBlocks),
226
283
  proof_of_work: toHex(params.proofOfWork),
227
284
  challenge_timestamp: String(params.challengeTimestamp),
228
- resource_requirements: [
229
- { kind: 'CPU', count: 1 },
230
- { kind: 'MemoryMB', count: 1024 },
231
- { kind: 'StorageMB', count: 10240 },
232
- ],
285
+ require_tee: params.requireTee,
286
+ resource_requirements: DEFAULT_RESOURCE_REQUIREMENTS,
233
287
  }),
234
288
  signal: AbortSignal.timeout(10_000),
235
289
  });
@@ -242,16 +296,17 @@ async function fetchPriceFromOperator(
242
296
  totalCost: BigInt(data.total_cost ?? '0'),
243
297
  signature: (data.signature ?? '0x') as `0x${string}`,
244
298
  costRate: Number(data.cost_rate ?? 0),
299
+ teeAttested: Boolean(data.tee_attested),
300
+ teeProvider: data.tee_provider || undefined,
245
301
  details: {
246
302
  blueprintId: BigInt(data.details?.blueprint_id ?? params.blueprintId),
247
303
  ttlBlocks: BigInt(data.details?.ttl_blocks ?? params.ttlBlocks),
248
304
  totalCost: BigInt(data.details?.total_cost ?? '0'),
249
305
  timestamp: BigInt(data.details?.timestamp ?? params.challengeTimestamp),
250
306
  expiry: BigInt(data.details?.expiry ?? '0'),
251
- securityCommitments: (data.details?.security_commitments ?? []).map((sc: any) => ({
252
- asset: { kind: sc.asset?.kind ?? 0, token: (sc.asset?.token ?? '0x0000000000000000000000000000000000000000') as Address },
253
- exposureBps: sc.exposure_bps ?? 0,
254
- })),
307
+ confidentiality: Number(data.details?.confidentiality ?? quoteConfidentiality(params.requireTee)),
308
+ securityCommitments: (data.details?.security_commitments ?? []).map(mapJsonSecurityCommitment),
309
+ resourceCommitments: (data.details?.resources ?? []).map(mapJsonResourceCommitment),
255
310
  },
256
311
  };
257
312
  } catch {
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useState } from 'react';
2
2
  import { useSignMessage } from 'wagmi';
3
3
  import { getSession, setSession, removeSession, type SessionEntry } from '../stores/session';
4
+ import { getEnvVar } from '../utils/env';
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Types
@@ -28,7 +29,7 @@ interface UseSessionAuthOptions {
28
29
  }
29
30
 
30
31
  export function useSessionAuth({ sandboxId, apiUrl }: UseSessionAuthOptions) {
31
- const baseUrl = apiUrl ?? import.meta.env.VITE_OPERATOR_API_URL ?? 'http://localhost:9090';
32
+ const baseUrl = apiUrl ?? getEnvVar('VITE_OPERATOR_API_URL') ?? 'http://localhost:9090';
32
33
  const { signMessageAsync } = useSignMessage();
33
34
 
34
35
  const [isAuthenticating, setIsAuthenticating] = useState(false);
@@ -0,0 +1,173 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface UseSidecarAuthOptions {
8
+ /** Scoping key for token storage (e.g. botId, sandboxId). */
9
+ resourceId: string;
10
+ /** Base URL of the sidecar or operator API. */
11
+ apiUrl: string;
12
+ /**
13
+ * Sign a plaintext message and return the hex signature.
14
+ * Consuming apps wire this to their wallet library (e.g. wagmi's signMessageAsync).
15
+ */
16
+ signMessage: (message: string) => Promise<string>;
17
+ }
18
+
19
+ export interface SidecarAuth {
20
+ token: string | null;
21
+ isAuthenticated: boolean;
22
+ isAuthenticating: boolean;
23
+ authenticate: () => Promise<string | null>;
24
+ clearCachedToken: () => void;
25
+ error: string | null;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // localStorage helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function storageKey(resourceId: string, apiUrl: string): string {
33
+ return `sidecar_session_${resourceId}__${apiUrl}`;
34
+ }
35
+
36
+ function loadSession(resourceId: string, apiUrl: string): { token: string; expiresAt: number } | null {
37
+ if (typeof window === 'undefined') return null;
38
+ try {
39
+ const raw = localStorage.getItem(storageKey(resourceId, apiUrl));
40
+ if (!raw) return null;
41
+ const data = JSON.parse(raw) as { token: string; expiresAt: number };
42
+ // Discard if within 60s of expiry
43
+ if (data.expiresAt * 1000 - Date.now() < 60_000) {
44
+ localStorage.removeItem(storageKey(resourceId, apiUrl));
45
+ return null;
46
+ }
47
+ return data;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function saveSession(resourceId: string, apiUrl: string, token: string, expiresAt: number) {
54
+ if (typeof window === 'undefined') return;
55
+ try {
56
+ localStorage.setItem(storageKey(resourceId, apiUrl), JSON.stringify({ token, expiresAt }));
57
+ } catch {
58
+ // storage full — ignore
59
+ }
60
+ }
61
+
62
+ function clearSession(resourceId: string, apiUrl: string) {
63
+ if (typeof window === 'undefined') return;
64
+ localStorage.removeItem(storageKey(resourceId, apiUrl));
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Hook
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Generic sidecar PASETO challenge/response auth.
73
+ *
74
+ * Flow:
75
+ * 1. POST /api/auth/challenge -> { nonce, message, expires_at }
76
+ * 2. signMessage(message) -> signature (provided by consuming app)
77
+ * 3. POST /api/auth/session -> { token, address, expires_at }
78
+ *
79
+ * Tokens are cached in localStorage and auto-refreshed 5 minutes before expiry.
80
+ */
81
+ export function useSidecarAuth({ resourceId, apiUrl, signMessage }: UseSidecarAuthOptions): SidecarAuth {
82
+ const cached = loadSession(resourceId, apiUrl);
83
+ const [token, setToken] = useState<string | null>(cached?.token ?? null);
84
+ const [expiresAt, setExpiresAt] = useState<number>(cached?.expiresAt ?? 0);
85
+ const [isAuthenticating, setIsAuthenticating] = useState(false);
86
+ const [error, setError] = useState<string | null>(null);
87
+ const refreshTimerRef = useRef<ReturnType<typeof setTimeout>>();
88
+
89
+ const clearCachedToken = useCallback(() => {
90
+ setToken(null);
91
+ setExpiresAt(0);
92
+ clearSession(resourceId, apiUrl);
93
+ }, [resourceId, apiUrl]);
94
+
95
+ const authenticate = useCallback(async (): Promise<string | null> => {
96
+ if (!apiUrl) return null;
97
+ setIsAuthenticating(true);
98
+ setError(null);
99
+
100
+ try {
101
+ // Step 1: Get challenge
102
+ const challengeRes = await fetch(`${apiUrl}/api/auth/challenge`, {
103
+ method: 'POST',
104
+ });
105
+ if (!challengeRes.ok) {
106
+ throw new Error(`Challenge failed: ${challengeRes.status}`);
107
+ }
108
+ const { nonce, message } = await challengeRes.json();
109
+
110
+ // Step 2: Sign with wallet (injected)
111
+ const signature = await signMessage(message);
112
+
113
+ // Step 3: Exchange for session token
114
+ const sessionRes = await fetch(`${apiUrl}/api/auth/session`, {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ nonce, signature }),
118
+ });
119
+ if (!sessionRes.ok) {
120
+ const text = await sessionRes.text();
121
+ throw new Error(text || `Session exchange failed: ${sessionRes.status}`);
122
+ }
123
+
124
+ const { token: newToken, expires_at } = await sessionRes.json();
125
+ setToken(newToken);
126
+ setExpiresAt(expires_at);
127
+ saveSession(resourceId, apiUrl, newToken, expires_at);
128
+ return newToken;
129
+ } catch (err) {
130
+ setError(err instanceof Error ? err.message : 'Authentication failed');
131
+ clearCachedToken();
132
+ return null;
133
+ } finally {
134
+ setIsAuthenticating(false);
135
+ }
136
+ }, [resourceId, apiUrl, signMessage, clearCachedToken]);
137
+
138
+ // Auto-refresh token 5 minutes before expiry
139
+ useEffect(() => {
140
+ if (refreshTimerRef.current) {
141
+ clearTimeout(refreshTimerRef.current);
142
+ }
143
+
144
+ if (!token || !expiresAt) return;
145
+
146
+ const msUntilRefresh = (expiresAt - 300) * 1000 - Date.now();
147
+ if (msUntilRefresh <= 0) {
148
+ clearCachedToken();
149
+ return;
150
+ }
151
+
152
+ refreshTimerRef.current = setTimeout(() => {
153
+ authenticate().catch(() => {
154
+ clearCachedToken();
155
+ });
156
+ }, msUntilRefresh);
157
+
158
+ return () => {
159
+ if (refreshTimerRef.current) {
160
+ clearTimeout(refreshTimerRef.current);
161
+ }
162
+ };
163
+ }, [token, expiresAt, authenticate, clearCachedToken]);
164
+
165
+ return {
166
+ token,
167
+ isAuthenticated: token !== null,
168
+ isAuthenticating,
169
+ authenticate,
170
+ clearCachedToken,
171
+ error,
172
+ };
173
+ }
@@ -0,0 +1,11 @@
1
+ import { useSignMessage } from 'wagmi';
2
+ import { useSidecarAuth } from './useSidecarAuth';
3
+
4
+ export function useWagmiSidecarAuth(resourceId: string, apiUrl: string) {
5
+ const { signMessageAsync } = useSignMessage();
6
+ return useSidecarAuth({
7
+ resourceId,
8
+ apiUrl,
9
+ signMessage: (msg: string) => signMessageAsync({ message: msg }),
10
+ });
11
+ }