@tangle-network/blueprint-ui 0.1.0 → 0.1.1

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.
package/README.md CHANGED
@@ -74,28 +74,30 @@ Keep in app-local code:
74
74
  - **`registerBlueprint`** / **`getBlueprint`** — Register and look up blueprint definitions
75
75
  - **`JobDefinition`** — Declarative job schema with field types, ABI metadata, and categories
76
76
 
77
- ## Installation
78
77
  ## Installation
79
78
 
80
79
  ```bash
81
- # As a git dependency (recommended for Tangle apps)
82
- pnpm add github:tangle-network/blueprint-ui
80
+ npm install @tangle-network/blueprint-ui
81
+ # or
82
+ pnpm add @tangle-network/blueprint-ui
83
83
  ```
84
84
 
85
+ Package: https://www.npmjs.com/package/@tangle-network/blueprint-ui
86
+
85
87
  ## Publishing
86
88
 
87
89
  Automated npm publishing is configured via GitHub Actions with npm Trusted Publishing (OIDC):
88
90
  - Workflow: `.github/workflows/publish-npm.yml`
89
91
  - Triggers:
90
- - GitHub Release published (`vX.Y.Z`)
91
- - Manual `workflow_dispatch`
92
+ - Push tag `blueprint-ui-vX.Y.Z`
93
+ - Manual `workflow_dispatch` with `version` input
92
94
 
93
95
  No long-lived npm token is required once trusted publishing is configured.
94
96
 
95
97
  Release flow:
96
98
  1. Bump `package.json` version.
97
- 2. Create and publish a GitHub release tagged `v<same-version>`.
98
- 3. Workflow typechecks and runs `npm publish --access public`.
99
+ 2. Push tag `blueprint-ui-v<same-version>`.
100
+ 3. Workflow typechecks and runs `npm publish --provenance --access public`.
99
101
 
100
102
  Trusted publishing setup (one-time in npm):
101
103
  1. Open npm package settings for `@tangle-network/blueprint-ui`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/blueprint-ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Shared blueprint UI components, hooks, and contract utilities for Tangle Network apps",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -22,7 +22,7 @@ function buildInlineThemeCode(themeStorageKeys: [string, string]): string {
22
22
 
23
23
  export function AppDocument({ children, description, themeStorageKeys }: AppDocumentProps) {
24
24
  return (
25
- <html lang="en" data-theme="dark">
25
+ <html lang="en" data-theme="dark" suppressHydrationWarning>
26
26
  <head>
27
27
  <meta charSet="utf-8" />
28
28
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -1,15 +1,32 @@
1
1
  import { useState, useRef, useEffect } from 'react';
2
2
  import { useStore } from '@nanostores/react';
3
3
  import { selectedChainIdStore } from '../../contracts/publicClient';
4
- import { getNetworks, tangleLocal, tangleTestnet, tangleMainnet } from '../../contracts/chains';
4
+ import { getNetworks } from '../../contracts/chains';
5
5
 
6
- const chainOrder = [tangleLocal.id, tangleTestnet.id, tangleMainnet.id];
6
+ function chainIcon(label: string | undefined, chainName: string | undefined): string {
7
+ if (label === 'Tangle Local' || chainName === 'Tangle Local') return 'i-ph:desktop';
8
+ if (label === 'Tangle Testnet' || chainName === 'Tangle Testnet') return 'i-ph:flask';
9
+ if (label === 'Tangle Mainnet' || chainName === 'Tangle') return 'i-ph:globe-hemisphere-west';
10
+ return 'i-ph:globe';
11
+ }
12
+
13
+ function orderedChainIds(): number[] {
14
+ const priority: Record<string, number> = {
15
+ 'Tangle Local': 0,
16
+ 'Tangle Testnet': 1,
17
+ 'Tangle Mainnet': 2,
18
+ Tangle: 2,
19
+ };
7
20
 
8
- const chainIcons: Record<number, string> = {
9
- [tangleLocal.id]: 'i-ph:desktop',
10
- [tangleTestnet.id]: 'i-ph:flask',
11
- [tangleMainnet.id]: 'i-ph:globe-hemisphere-west',
12
- };
21
+ return Object.entries(getNetworks())
22
+ .sort(([, a], [, b]) => {
23
+ const aPriority = priority[a?.label ?? a?.chain?.name ?? ''] ?? 99;
24
+ const bPriority = priority[b?.label ?? b?.chain?.name ?? ''] ?? 99;
25
+ if (aPriority !== bPriority) return aPriority - bPriority;
26
+ return a.chain.id - b.chain.id;
27
+ })
28
+ .map(([chainId]) => Number(chainId));
29
+ }
13
30
 
14
31
  export function ChainSwitcher() {
15
32
  const [open, setOpen] = useState(false);
@@ -38,7 +55,7 @@ export function ChainSwitcher() {
38
55
  className="flex items-center gap-1.5 px-2.5 py-2 rounded-lg text-xs font-data font-medium bg-bp-elements-background-depth-3 dark:bg-bp-elements-background-depth-4 border border-bp-elements-borderColor hover:border-bp-elements-borderColorActive/40 transition-all"
39
56
  title={current?.label ?? 'Select network'}
40
57
  >
41
- <div className={`${chainIcons[selectedChainId] ?? 'i-ph:globe'} text-sm text-bp-elements-icon-success`} />
58
+ <div className={`${chainIcon(current?.label, current?.chain?.name)} text-sm text-bp-elements-icon-success`} />
42
59
  <span className="hidden sm:inline text-bp-elements-textSecondary">{current?.shortLabel ?? 'Unknown'}</span>
43
60
  <div className={`i-ph:caret-down text-[10px] text-bp-elements-textTertiary transition-transform ${open ? 'rotate-180' : ''}`} />
44
61
  </button>
@@ -46,7 +63,7 @@ export function ChainSwitcher() {
46
63
  {open && (
47
64
  <div className="absolute right-0 top-full mt-2 w-48 glass-card-strong rounded-xl border border-bp-elements-dividerColor/50 py-1.5 z-50 shadow-lg">
48
65
  <div className="px-3 py-1.5 text-[10px] font-data uppercase tracking-wider text-bp-elements-textTertiary">Network</div>
49
- {chainOrder.map((chainId) => {
66
+ {orderedChainIds().map((chainId) => {
50
67
  const net = getNetworks()[chainId];
51
68
  if (!net) return null;
52
69
  const isSelected = chainId === selectedChainId;
@@ -58,7 +75,7 @@ export function ChainSwitcher() {
58
75
  isSelected ? 'bg-violet-500/10 text-violet-700 dark:text-violet-400' : 'hover:bg-bp-elements-item-backgroundHover text-bp-elements-textSecondary'
59
76
  }`}
60
77
  >
61
- <div className={`${chainIcons[chainId] ?? 'i-ph:globe'} text-sm ${isSelected ? 'text-violet-700 dark:text-violet-400' : 'text-bp-elements-textTertiary'}`} />
78
+ <div className={`${chainIcon(net.label, net.chain.name)} text-sm ${isSelected ? 'text-violet-700 dark:text-violet-400' : 'text-bp-elements-textTertiary'}`} />
62
79
  <span className="text-sm font-display font-medium">{net.label}</span>
63
80
  {isSelected && <div className="i-ph:check-bold text-xs ml-auto text-violet-700 dark:text-violet-400" />}
64
81
  </button>
@@ -1,13 +1,83 @@
1
1
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
- import { type ReactNode, useState } from 'react';
3
- import { type Config, WagmiProvider } from 'wagmi';
2
+ import { type ReactNode, useEffect, useRef, useState } from 'react';
3
+ import { type Config, WagmiContext, useReconnect } from 'wagmi';
4
4
 
5
5
  interface Web3ShellProps {
6
6
  config: Config;
7
+ reconnectOnMount?: boolean;
7
8
  children: ReactNode;
8
9
  }
9
10
 
10
- export function Web3Shell({ config, children }: Web3ShellProps) {
11
+ type RecentConnectorStorage = Pick<NonNullable<Config['storage']>, 'getItem' | 'removeItem'>;
12
+
13
+ // We prefer the last successful connector so silent restoration is fast and
14
+ // doesn't probe every available connector on startup.
15
+ async function findRecentConnector(currentConfig: Pick<Config, 'connectors' | 'storage'>) {
16
+ const storage = currentConfig.storage as RecentConnectorStorage | null | undefined;
17
+ const recentConnectorId = await storage?.getItem('recentConnectorId');
18
+ if (!recentConnectorId) return null;
19
+
20
+ const connector = currentConfig.connectors.find((candidate) => candidate.id === recentConnectorId) ?? null;
21
+ if (!connector) {
22
+ await storage?.removeItem('recentConnectorId');
23
+ }
24
+
25
+ return connector;
26
+ }
27
+
28
+ function ReconnectOnMount({
29
+ config,
30
+ reconnectOnMount,
31
+ }: Pick<Web3ShellProps, 'config' | 'reconnectOnMount'>) {
32
+ const { reconnectAsync } = useReconnect();
33
+ const attemptedRef = useRef(false);
34
+
35
+ useEffect(() => {
36
+ if (!reconnectOnMount || attemptedRef.current) return;
37
+ attemptedRef.current = true;
38
+
39
+ let cancelled = false;
40
+
41
+ // Why this exists:
42
+ // - In this app stack, wagmi's built-in provider hydrate path can kick off
43
+ // reconnect work during provider rerenders.
44
+ // - Route navigation rerenders the app shell, so wallet restore was being
45
+ // retriggered on page changes, which made the header look like it was
46
+ // reconnecting and added several seconds of delay.
47
+ //
48
+ // Our fix is to keep wagmi context/query wiring here, but move restore to a
49
+ // single effect guarded by a ref. A rerender keeps the same ref value, so
50
+ // reconnect only runs once per real mount and still runs again after a full
51
+ // page refresh.
52
+ void (async () => {
53
+ try {
54
+ const connector = await findRecentConnector(config);
55
+ if (cancelled) return;
56
+
57
+ if (connector) {
58
+ await reconnectAsync({ connectors: [connector] });
59
+ return;
60
+ }
61
+
62
+ await reconnectAsync();
63
+ } catch {
64
+ // A failed silent reconnect should leave the app disconnected.
65
+ }
66
+ })();
67
+
68
+ return () => {
69
+ cancelled = true;
70
+ };
71
+ }, [config, reconnectAsync, reconnectOnMount]);
72
+
73
+ return null;
74
+ }
75
+
76
+ export function Web3Shell({
77
+ config,
78
+ reconnectOnMount = true,
79
+ children,
80
+ }: Web3ShellProps) {
11
81
  const [queryClient] = useState(
12
82
  () =>
13
83
  new QueryClient({
@@ -21,8 +91,13 @@ export function Web3Shell({ config, children }: Web3ShellProps) {
21
91
  );
22
92
 
23
93
  return (
24
- <WagmiProvider config={config}>
25
- <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
26
- </WagmiProvider>
94
+ // We provide wagmi context directly instead of using WagmiProvider so we can
95
+ // control exactly when silent reconnect runs.
96
+ <WagmiContext.Provider value={config}>
97
+ <QueryClientProvider client={queryClient}>
98
+ <ReconnectOnMount config={config} reconnectOnMount={reconnectOnMount} />
99
+ {children}
100
+ </QueryClientProvider>
101
+ </WagmiContext.Provider>
27
102
  );
28
103
  }
@@ -0,0 +1,21 @@
1
+ interface ConnectWalletCtaProps {
2
+ onClick?: () => void;
3
+ isReconnecting?: boolean;
4
+ }
5
+
6
+ export function ConnectWalletCta({ onClick, isReconnecting = false }: ConnectWalletCtaProps) {
7
+ return (
8
+ <button
9
+ onClick={() => onClick?.()}
10
+ disabled={!onClick}
11
+ className="px-4 py-2.5 rounded-lg bg-violet-500/10 border border-violet-500/20 text-violet-700 dark:text-violet-400 text-sm font-display font-medium hover:bg-violet-500/20 transition-colors"
12
+ >
13
+ {isReconnecting ? (
14
+ <span className="flex items-center gap-2">
15
+ <span className="w-3 h-3 rounded-full border-2 border-violet-500/40 border-t-violet-600 dark:border-t-violet-400 animate-spin" />
16
+ Reconnecting...
17
+ </span>
18
+ ) : 'Connect'}
19
+ </button>
20
+ );
21
+ }
package/src/components.ts CHANGED
@@ -43,6 +43,9 @@ export { Web3Shell } from './components/layout/Web3Shell';
43
43
  export { ChainSwitcher } from './components/layout/ChainSwitcher';
44
44
  export { ThemeToggle } from './components/layout/ThemeToggle';
45
45
 
46
+ // ── Web3 ──
47
+ export { ConnectWalletCta } from './components/web3/ConnectWalletCta';
48
+
46
49
  // ── Forms ──
47
50
  export { FormField } from './components/forms/FormField';
48
51
  export type { FormSection } from './components/forms/BlueprintJobForm';
@@ -185,9 +185,9 @@ export const tangleServicesAbi = [
185
185
  type: 'event',
186
186
  name: 'ServiceRequested',
187
187
  inputs: [
188
- { name: 'requester', type: 'address', indexed: true },
189
188
  { name: 'requestId', type: 'uint64', indexed: true },
190
189
  { name: 'blueprintId', type: 'uint64', indexed: true },
190
+ { name: 'requester', type: 'address', indexed: true },
191
191
  ],
192
192
  },
193
193
  {
@@ -31,14 +31,26 @@ export function resolveRpcUrl(envUrl?: string): string {
31
31
 
32
32
  export const rpcUrl = resolveRpcUrl();
33
33
 
34
- export const tangleLocal = defineChain({
35
- id: Number(import.meta.env.VITE_CHAIN_ID ?? 31337),
36
- name: 'Tangle Local',
37
- nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
38
- rpcUrls: { default: { http: [rpcUrl] } },
39
- blockExplorers: { default: { name: 'Explorer', url: '' } },
40
- contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
41
- });
34
+ export interface LocalChainOptions {
35
+ chainId?: number;
36
+ rpcUrl?: string;
37
+ }
38
+
39
+ export function createTangleLocalChain(options: LocalChainOptions = {}) {
40
+ const chainId = options.chainId ?? Number(import.meta.env.VITE_CHAIN_ID ?? 31337);
41
+ const localRpcUrl = resolveRpcUrl(options.rpcUrl);
42
+
43
+ return defineChain({
44
+ id: chainId,
45
+ name: 'Tangle Local',
46
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
47
+ rpcUrls: { default: { http: [localRpcUrl] } },
48
+ blockExplorers: { default: { name: 'Explorer', url: '' } },
49
+ contracts: { multicall3: { address: '0xcA11bde05977b3631167028862bE2a173976CA11' } },
50
+ });
51
+ }
52
+
53
+ export const tangleLocal = createTangleLocalChain();
42
54
 
43
55
  export const tangleTestnet = defineChain({
44
56
  id: 3799,
@@ -13,36 +13,76 @@ export const selectedChainIdStore = persistedAtom<number>({
13
13
 
14
14
  const clientCache = new Map<number, PublicClient>();
15
15
 
16
+ function configuredDefaultChainId(): number {
17
+ const networks = getNetworks();
18
+ if (networks[defaultChainId]) return defaultChainId;
19
+
20
+ for (const [chainId, net] of Object.entries(networks)) {
21
+ if (
22
+ net?.shortLabel === 'Local' ||
23
+ net?.label === 'Tangle Local' ||
24
+ net?.chain?.name === 'Tangle Local'
25
+ ) {
26
+ return Number(chainId);
27
+ }
28
+ }
29
+
30
+ const [firstConfigured] = Object.keys(networks);
31
+ return firstConfigured ? Number(firstConfigured) : defaultChainId;
32
+ }
33
+
34
+ function normalizeSelectedChainId(chainId: number): number {
35
+ const networks = getNetworks();
36
+ if (!Object.keys(networks).length) return chainId;
37
+ return networks[chainId] ? chainId : configuredDefaultChainId();
38
+ }
39
+
40
+ export function sanitizeSelectedChainId(): number {
41
+ const normalized = normalizeSelectedChainId(selectedChainIdStore.get());
42
+ if (normalized !== selectedChainIdStore.get()) {
43
+ selectedChainIdStore.set(normalized);
44
+ }
45
+ return normalized;
46
+ }
47
+
16
48
  function getOrCreateClient(chainId: number): PublicClient {
17
- const cached = clientCache.get(chainId);
49
+ const normalizedChainId = normalizeSelectedChainId(chainId);
50
+
51
+ const cached = clientCache.get(normalizedChainId);
18
52
  if (cached) return cached;
19
53
  const networks = getNetworks();
20
- const net = networks[chainId];
54
+ const net = networks[normalizedChainId];
21
55
  if (!net) {
22
- const fallback = networks[defaultChainId];
56
+ const fallback = networks[configuredDefaultChainId()];
23
57
  if (!fallback) {
24
58
  return createPublicClient({ chain: tangleLocal, transport: http() });
25
59
  }
26
60
  return createPublicClient({ chain: fallback.chain, transport: http(fallback.rpcUrl) });
27
61
  }
28
62
  const client = createPublicClient({ chain: net.chain, transport: http(net.rpcUrl) });
29
- clientCache.set(chainId, client);
63
+ clientCache.set(normalizedChainId, client);
30
64
  return client;
31
65
  }
32
66
 
33
- export const publicClientStore = atom<PublicClient>(getOrCreateClient(selectedChainIdStore.get()));
67
+ export const publicClientStore = atom<PublicClient>(getOrCreateClient(sanitizeSelectedChainId()));
34
68
 
35
69
  selectedChainIdStore.subscribe((chainId: number) => {
36
- publicClientStore.set(getOrCreateClient(chainId));
70
+ const normalized = normalizeSelectedChainId(chainId);
71
+ if (normalized !== chainId) {
72
+ selectedChainIdStore.set(normalized);
73
+ return;
74
+ }
75
+ publicClientStore.set(getOrCreateClient(normalized));
37
76
  });
38
77
 
39
78
  export function getPublicClient(): PublicClient {
79
+ sanitizeSelectedChainId();
40
80
  return publicClientStore.get();
41
81
  }
42
82
 
43
83
  export const publicClient = new Proxy({} as PublicClient, {
44
84
  get(_target, prop) {
45
- const client = getOrCreateClient(selectedChainIdStore.get());
85
+ const client = getOrCreateClient(sanitizeSelectedChainId());
46
86
  const value = (client as any)[prop];
47
87
  return typeof value === 'function' ? value.bind(client) : value;
48
88
  },
@@ -50,6 +90,7 @@ export const publicClient = new Proxy({} as PublicClient, {
50
90
 
51
91
  export function getAddresses<T extends CoreAddresses = CoreAddresses>(): T {
52
92
  const networks = getNetworks<T>();
53
- const net = networks[selectedChainIdStore.get()];
54
- return net?.addresses ?? networks[defaultChainId]?.addresses ?? {} as T;
93
+ const selectedChainId = sanitizeSelectedChainId();
94
+ const net = networks[selectedChainId];
95
+ return net?.addresses ?? networks[configuredDefaultChainId()]?.addresses ?? {} as T;
55
96
  }
@@ -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)));
@@ -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
+ }
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ const WEI_PER_ETH = 1_000_000_000_000_000_000n;
4
+ const DISPLAY_SCALE = 1_000n; // 3 decimal places
5
+
6
+ function formatEthBalance(wei: bigint): string {
7
+ const whole = wei / WEI_PER_ETH;
8
+ const fraction = ((wei % WEI_PER_ETH) * DISPLAY_SCALE) / WEI_PER_ETH;
9
+ return `${whole.toString()}.${fraction.toString().padStart(3, '0')}`;
10
+ }
11
+
12
+ interface UseWalletEthBalanceOptions {
13
+ address?: string;
14
+ refreshKey?: number | string;
15
+ readBalance: (address: string) => Promise<bigint>;
16
+ pollMs?: number;
17
+ onError?: (error: unknown) => void;
18
+ }
19
+
20
+ interface UseWalletEthBalanceResult {
21
+ balance: string | null;
22
+ hasError: boolean;
23
+ }
24
+
25
+ export function useWalletEthBalance({
26
+ address,
27
+ refreshKey,
28
+ readBalance,
29
+ pollMs = 15_000,
30
+ onError,
31
+ }: UseWalletEthBalanceOptions): UseWalletEthBalanceResult {
32
+ const [balance, setBalance] = useState<string | null>(null);
33
+ const [hasError, setHasError] = useState(false);
34
+
35
+ useEffect(() => {
36
+ if (!address) {
37
+ setBalance(null);
38
+ setHasError(false);
39
+ return;
40
+ }
41
+
42
+ let cancelled = false;
43
+
44
+ const fetchBalance = () => {
45
+ readBalance(address)
46
+ .then((wei) => {
47
+ if (cancelled) return;
48
+ setBalance(formatEthBalance(wei));
49
+ setHasError(false);
50
+ })
51
+ .catch((error: unknown) => {
52
+ if (cancelled) return;
53
+ setHasError(true);
54
+ onError?.(error);
55
+ });
56
+ };
57
+
58
+ fetchBalance();
59
+ const interval = setInterval(fetchBalance, pollMs);
60
+
61
+ return () => {
62
+ cancelled = true;
63
+ clearInterval(interval);
64
+ };
65
+ }, [address, refreshKey, readBalance, pollMs, onError]);
66
+
67
+ return { balance, hasError };
68
+ }
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // ── Utils ──
7
7
  export { cn } from './utils';
8
8
  export { resolveOperatorRpc } from './utils/resolveOperatorRpc';
9
- export { createTangleTransports, defaultConnectKitOptions, tangleWalletChains } from './utils/web3';
9
+ export { createTangleTransports, defaultConnectKitOptions, getTangleWalletChains, tangleWalletChains } from './utils/web3';
10
10
  export { bpThemeTokens } from './preset';
11
11
 
12
12
  // ── Stores ──
@@ -25,6 +25,7 @@ export { tangleJobsAbi, tangleServicesAbi, tangleOperatorsAbi } from './contract
25
25
  export type { CoreAddresses, NetworkConfig } from './contracts/chains';
26
26
  export {
27
27
  resolveRpcUrl,
28
+ createTangleLocalChain,
28
29
  rpcUrl,
29
30
  tangleLocal,
30
31
  tangleTestnet,
@@ -36,6 +37,7 @@ export {
36
37
  } from './contracts/chains';
37
38
  export {
38
39
  selectedChainIdStore,
40
+ sanitizeSelectedChainId,
39
41
  publicClientStore,
40
42
  getPublicClient,
41
43
  publicClient,
@@ -77,3 +79,7 @@ export { useSessionAuth } from './hooks/useSessionAuth';
77
79
  export type { ProvisionPhase, ProvisionStatus } from './hooks/useProvisionProgress';
78
80
  export { getPhaseLabel, isTerminalPhase, useProvisionProgress } from './hooks/useProvisionProgress';
79
81
  export { useThemeValue } from './hooks/useThemeValue';
82
+ export type { UseSidecarAuthOptions, SidecarAuth } from './hooks/useSidecarAuth';
83
+ export { useSidecarAuth } from './hooks/useSidecarAuth';
84
+ export { useWagmiSidecarAuth } from './hooks/useWagmiSidecarAuth';
85
+ export { useWalletEthBalance } from './hooks/useWalletEthBalance';
package/src/utils/web3.ts CHANGED
@@ -2,11 +2,17 @@ import type { Chain } from 'viem';
2
2
  import { http } from 'wagmi';
3
3
  import { mainnet, rpcUrl, tangleLocal, tangleMainnet, tangleTestnet } from '../contracts/chains';
4
4
 
5
- export const tangleWalletChains: readonly [Chain, ...Chain[]] = [tangleLocal, tangleTestnet, tangleMainnet, mainnet];
5
+ export function getTangleWalletChains(localChain: Chain = tangleLocal): readonly [Chain, ...Chain[]] {
6
+ return [localChain, tangleTestnet, tangleMainnet, mainnet];
7
+ }
8
+
9
+ export const tangleWalletChains: readonly [Chain, ...Chain[]] = getTangleWalletChains();
10
+
11
+ export function createTangleTransports(localChain: Pick<Chain, 'id' | 'rpcUrls'> = tangleLocal) {
12
+ const localRpcUrl = localChain.rpcUrls.default.http[0] ?? rpcUrl;
6
13
 
7
- export function createTangleTransports() {
8
14
  return {
9
- [tangleLocal.id]: http(rpcUrl),
15
+ [localChain.id]: http(localRpcUrl),
10
16
  [tangleTestnet.id]: http('https://testnet-rpc.tangle.tools'),
11
17
  [tangleMainnet.id]: http('https://rpc.tangle.tools'),
12
18
  [mainnet.id]: http(),