@tangle-network/blueprint-ui 0.1.1 → 0.3.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.
- package/README.md +38 -4
- package/dist/BlueprintHostPanel-L1KKLNbr.d.ts +124 -0
- package/dist/chunk-37ADATBT.js +55 -0
- package/dist/chunk-37ADATBT.js.map +1 -0
- package/dist/chunk-5PCH2RJF.js +1540 -0
- package/dist/chunk-5PCH2RJF.js.map +1 -0
- package/dist/components.d.ts +179 -0
- package/dist/components.js +1130 -0
- package/dist/components.js.map +1 -0
- package/dist/index.d.ts +8604 -0
- package/dist/index.js +839 -0
- package/dist/index.js.map +1 -0
- package/dist/preset.d.ts +60 -0
- package/dist/preset.js +7 -0
- package/dist/preset.js.map +1 -0
- package/dist/styles.css +560 -0
- package/dist/wallet/index.d.ts +188 -0
- package/dist/wallet/index.js +466 -0
- package/dist/wallet/index.js.map +1 -0
- package/package.json +39 -9
- package/src/components/forms/JobExecutionDialog.tsx +10 -2
- package/src/components.ts +3 -0
- package/src/contracts/abi.ts +12 -0
- package/src/contracts/chains.ts +4 -3
- package/src/contracts/publicClient.ts +2 -1
- package/src/hooks/useJobPrice.test.ts +214 -0
- package/src/hooks/useJobPrice.ts +56 -2
- package/src/hooks/useProvisionProgress.ts +2 -1
- package/src/hooks/useQuotes.ts +112 -14
- package/src/hooks/useSessionAuth.ts +2 -1
- package/src/host/components/BlueprintHostHero.tsx +91 -0
- package/src/host/components/BlueprintHostPanel.tsx +24 -0
- package/src/host/index.ts +42 -0
- package/src/host/resolver.ts +204 -0
- package/src/host/types.ts +111 -0
- package/src/index.ts +41 -1
- package/src/stores/infra.ts +3 -2
- package/src/styles.css +128 -0
- package/src/test-setup.ts +1 -0
- package/src/utils/env.ts +22 -0
- package/src/wallet/detectParentOrigin.ts +74 -0
- package/src/wallet/index.ts +67 -0
- package/src/wallet/parentBridgeConnector.ts +156 -0
- package/src/wallet/parentBridgeProtocol.ts +109 -0
- package/src/wallet/parentBridgeProvider.test.ts +209 -0
- package/src/wallet/parentBridgeProvider.ts +411 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/wallet/detectParentOrigin.ts","../../src/wallet/parentBridgeConnector.ts","../../src/wallet/parentBridgeProtocol.ts","../../src/wallet/parentBridgeProvider.ts"],"sourcesContent":["// Determine which origin to trust as the parent dapp.\n//\n// `document.referrer` is the *initial* embedder — it's set when the iframe is\n// first loaded and survives reloads (though it can be cleared by `referrerpolicy`\n// or by the embedder). The Tangle Cloud iframe wrapper deliberately omits\n// `referrerpolicy=\"no-referrer\"` so we get the embedder's origin here.\n//\n// We compare it against an allowlist of known Tangle Cloud origins. If it\n// matches, that's the parent. Otherwise the iframe is being loaded directly\n// (standalone domain visit, dev server, untrusted embedder) and the bridge\n// stays disabled — the app falls back to its normal injected/WC wallet path.\n\n/**\n * Default Tangle Cloud origins. Consumers (agent-sandbox UI,\n * trading-arena, future iframe blueprints) pass app-specific additions\n * via `extraOrigins` rather than mutating this list.\n */\nexport const TANGLE_CLOUD_ORIGINS_DEFAULT = Object.freeze([\n 'https://cloud.tangle.tools',\n 'https://develop.cloud.tangle.tools',\n // Local dev (Vite default port for tangle-cloud + Netlify dev preview).\n 'http://localhost:4300',\n 'http://localhost:8888',\n] as const);\n\nfunction originFromReferrer(): string | null {\n if (typeof document === 'undefined') return null;\n const ref = document.referrer;\n if (!ref) return null;\n try {\n return new URL(ref).origin;\n } catch {\n return null;\n }\n}\n\n/**\n * Returns the parent origin to bridge to, or null when no trusted parent is\n * detected. Caller should skip installing the bridge connector when this\n * returns null.\n *\n * `extraOrigins` is the application's escape hatch for staging or dev\n * deploys not covered by the default list. The library deliberately does\n * not read environment variables itself (consumers may bundle for non-Vite\n * runtimes); the consuming app threads `import.meta.env.VITE_*` or\n * `process.env.*` in itself.\n *\n * Falls back to a `?parent=<origin>` query parameter when no referrer is\n * present (some browsers strip referrer from cross-origin loads). Useful\n * for dev embedding flows.\n */\nexport function detectTangleCloudParentOrigin(\n options: { extraOrigins?: readonly string[] } = {},\n): string | null {\n if (typeof window === 'undefined' || window.parent === window) {\n return null;\n }\n const allowlist = new Set<string>([\n ...TANGLE_CLOUD_ORIGINS_DEFAULT,\n ...(options.extraOrigins ?? []),\n ]);\n const referrerOrigin = originFromReferrer();\n if (referrerOrigin && allowlist.has(referrerOrigin)) {\n return referrerOrigin;\n }\n try {\n const url = new URL(window.location.href);\n const explicit = url.searchParams.get('parent');\n if (explicit && allowlist.has(explicit)) return explicit;\n } catch {\n // ignore\n }\n return null;\n}\n","// Wagmi connector that proxies wallet operations to the Tangle Cloud parent\n// dapp via the iframe postMessage bridge. Becomes the autoConnect target\n// when this app is loaded inside an iframe sandbox without a window.ethereum\n// — i.e. always, when embedded by cloud.tangle.tools.\n//\n// Architecture: the connector owns one `ParentBridgeProvider` (singleton),\n// forwards every wagmi method to it, and reflects the provider's EIP-1193\n// events back to wagmi's emitter so the rest of the dapp (ConnectKit's\n// account chip, hooks like useAccount/useChainId) reacts to parent-state\n// changes without polling.\n\nimport type { Address, Chain } from 'viem';\nimport { createConnector } from 'wagmi';\n\nimport { ParentBridgeProvider, type ParentBridgeOptions } from './parentBridgeProvider';\n\nexport type ParentBridgeConnectorOptions = ParentBridgeOptions;\n\nexport function parentBridgeConnector(options: ParentBridgeConnectorOptions) {\n let provider: ParentBridgeProvider | undefined;\n let installed = false;\n\n return createConnector<ParentBridgeProvider>((config) => {\n const ensureProvider = (): ParentBridgeProvider => {\n if (!provider) provider = new ParentBridgeProvider(options);\n if (!installed) {\n provider.install();\n installed = true;\n // Wire the provider's EIP-1193 events to wagmi's emitter so\n // ConnectKit and useAccount/useChainId reflect parent-state changes\n // without polling.\n provider.on('accountsChanged', (accounts) => {\n config.emitter.emit('change', {\n accounts: Array.isArray(accounts)\n ? (accounts as readonly Address[])\n : ([] as readonly Address[]),\n });\n });\n provider.on('chainChanged', (chainIdHex) => {\n const chainId =\n typeof chainIdHex === 'string'\n ? Number.parseInt(chainIdHex, 16)\n : Number(chainIdHex);\n if (Number.isFinite(chainId)) {\n config.emitter.emit('change', { chainId });\n }\n });\n provider.on('disconnect', () => {\n config.emitter.emit('disconnect');\n });\n }\n return provider;\n };\n\n return {\n id: 'tangleParentBridge',\n name: 'Tangle Cloud',\n type: 'parentBridge',\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n async connect(): Promise<any> {\n // wagmi v3's connect() return type is a conditional based on\n // `withCapabilities`. We always return plain addresses; cast through\n // `any` rather than re-implementing the type predicate.\n const p = ensureProvider();\n const accountsResult = (await p.request({\n method: 'eth_requestAccounts',\n })) as readonly Address[];\n const chainIdHex = (await p.request({ method: 'eth_chainId' })) as string;\n const chainId = Number.parseInt(chainIdHex, 16);\n return {\n accounts: accountsResult,\n chainId: Number.isFinite(chainId) ? chainId : 0,\n };\n },\n\n async disconnect() {\n // Disconnect from the iframe's perspective is a local-only state\n // reset — we can't ask the parent dapp to disconnect its wallet on\n // our behalf, and a real disconnect should be initiated from the\n // parent's UI. Tear down listeners + the message bridge so a future\n // reconnect re-handshakes cleanly.\n if (provider) provider.uninstall();\n installed = false;\n provider = undefined;\n },\n\n async getAccounts() {\n const p = ensureProvider();\n const cached = p.getCachedAccount();\n if (cached) return [cached];\n const accounts = (await p.request({\n method: 'eth_accounts',\n })) as readonly Address[];\n return accounts;\n },\n\n async getChainId() {\n const p = ensureProvider();\n const cached = p.getCachedChainId();\n if (cached !== null) return cached;\n const chainIdHex = (await p.request({ method: 'eth_chainId' })) as string;\n const chainId = Number.parseInt(chainIdHex, 16);\n return Number.isFinite(chainId) ? chainId : 0;\n },\n\n async getProvider() {\n return ensureProvider();\n },\n\n async isAuthorized() {\n // Always authorized when in iframe mode — the parent dapp has\n // already gated access by being the embedder. Returning `true`\n // makes wagmi auto-reconnect on every page load, which is the\n // right UX (iframe → parent wallet is always-on).\n try {\n const p = ensureProvider();\n const accounts = (await p.request({\n method: 'eth_accounts',\n })) as readonly Address[];\n return accounts.length > 0;\n } catch {\n return false;\n }\n },\n\n async switchChain({ chainId }): Promise<Chain> {\n const p = ensureProvider();\n await p.request({\n method: 'wallet_switchEthereumChain',\n params: [{ chainId: `0x${chainId.toString(16)}` }],\n });\n const chain = config.chains.find((c) => c.id === chainId);\n if (!chain) {\n throw new Error(`Chain ${chainId} not configured for this app`);\n }\n return chain;\n },\n\n onAccountsChanged(accounts) {\n config.emitter.emit('change', {\n accounts: accounts as readonly Address[],\n });\n },\n onChainChanged(chainIdHex) {\n const chainId = Number.parseInt(chainIdHex, 16);\n if (Number.isFinite(chainId)) {\n config.emitter.emit('change', { chainId });\n }\n },\n onDisconnect() {\n config.emitter.emit('disconnect');\n },\n };\n });\n}\n","// Tangle Cloud iframe ↔ parent dapp protocol — must mirror the parent's\n// spec at `apps/tangle-cloud/src/blueprintApps/iframe/protocol.ts`. Bump the\n// version constant in lockstep when either side adds a request kind.\n\nimport type { Address, Hex } from 'viem';\n\nexport const TANGLE_IFRAME_PROTOCOL_VERSION = '1' as const;\nexport const TANGLE_IFRAME_PROTOCOL_PREFIX = 'tangle.app.';\n\n// ─── Iframe → Parent requests ────────────────────────────────────────────────\n\nexport type HandshakeRequest = {\n kind: 'tangle.app.handshake';\n appId: string;\n version: typeof TANGLE_IFRAME_PROTOCOL_VERSION;\n};\n\nexport type ReadAccountRequest = {\n kind: 'tangle.app.readAccount';\n correlationId: string;\n};\n\nexport type SwitchChainRequest = {\n kind: 'tangle.app.switchChain';\n correlationId: string;\n chainId: number;\n};\n\nexport type SignMessageRequest = {\n kind: 'tangle.app.signMessage';\n correlationId: string;\n chainId: number;\n message: string;\n};\n\nexport type SignTransactionRequest = {\n kind: 'tangle.app.signTransaction';\n correlationId: string;\n chainId: number;\n to: Address;\n data: Hex;\n value?: string;\n};\n\n// ─── Parent → Iframe messages ────────────────────────────────────────────────\n\nexport type HandshakeAck = {\n kind: 'tangle.app.handshakeAck';\n appId: string;\n protocolVersion: typeof TANGLE_IFRAME_PROTOCOL_VERSION;\n};\n\nexport type ResultEnvelope<T> = { correlationId: string } & (\n | { ok: true; data: T }\n | { ok: false; error: string }\n);\n\nexport type ReadAccountResult = {\n kind: 'tangle.app.readAccountResult';\n} & ResultEnvelope<{ account: Address; chainId: number }>;\n\nexport type SwitchChainResult = {\n kind: 'tangle.app.switchChainResult';\n} & ResultEnvelope<{ chainId: number }>;\n\nexport type SignMessageResult = {\n kind: 'tangle.app.signMessageResult';\n} & ResultEnvelope<{ signature: Hex }>;\n\nexport type SignTransactionResult = {\n kind: 'tangle.app.signTransactionResult';\n} & ResultEnvelope<{ txHash: Hex }>;\n\nexport type AccountChanged = {\n kind: 'tangle.app.accountChanged';\n account: Address | null;\n};\n\nexport type ChainChanged = {\n kind: 'tangle.app.chainChanged';\n chainId: number;\n};\n\nexport type ParentMessage =\n | HandshakeAck\n | ReadAccountResult\n | SwitchChainResult\n | SignMessageResult\n | SignTransactionResult\n | AccountChanged\n | ChainChanged;\n\n// The zero address used by the parent when no wallet is connected. The parent\n// always responds to readAccount with an address; this sentinel means \"no\n// wallet\" without making the response type a union of result shapes.\nexport const NO_WALLET_ADDRESS = '0x0000000000000000000000000000000000000000';\n\n/**\n * Cryptographically-random ASCII correlation id matching the parent's\n * validator regex (`/^[\\w.\\-:]+$/`, max length 128). The connector keeps a\n * Map<correlationId, Resolver> so each request resolves independently.\n */\nexport function makeCorrelationId(prefix: string): string {\n const random =\n typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'\n ? crypto.randomUUID()\n : Math.random().toString(36).slice(2) + Date.now().toString(36);\n return `${prefix}.${random}`;\n}\n","// EIP-1193 provider implementation that proxies wallet calls to the parent\n// dapp via window.postMessage. The iframe doesn't talk to a wallet directly\n// — it inherits the parent's connected account + chain, and forwards signing\n// requests through the existing tangle.app.* protocol.\n//\n// This is the lowest layer of the parent-bridge stack. Wagmi sees this as a\n// regular Ethereum provider and routes `eth_accounts`, `eth_chainId`,\n// `personal_sign`, `eth_sendTransaction`, `wallet_switchEthereumChain`, etc.\n// through it.\n\nimport type { Address, Hex } from 'viem';\n\nimport {\n makeCorrelationId,\n NO_WALLET_ADDRESS,\n TANGLE_IFRAME_PROTOCOL_VERSION,\n type ParentMessage,\n} from './parentBridgeProtocol';\n\ntype EventName = 'accountsChanged' | 'chainChanged' | 'connect' | 'disconnect' | 'message';\ntype Listener = (...args: unknown[]) => void;\n\ntype PendingRequest<T> = {\n resolve: (value: T) => void;\n reject: (reason: Error) => void;\n expectedKind: ParentMessage['kind'];\n};\n\nexport type ParentBridgeOptions = {\n /**\n * Origin of the parent dapp that hosts this iframe. The provider posts to\n * `window.parent` with this exact origin and rejects inbound messages from\n * any other origin. Pass `'*'` only in development; production must pin to\n * the real parent (`https://cloud.tangle.tools` or its develop equivalent).\n */\n parentOrigin: string;\n /**\n * Stable identifier for this iframe app. The parent includes this in the\n * handshake ack so dev tooling can correlate logs across the two windows.\n */\n appId: string;\n /**\n * Optional ms timeout for each bridged request. Defaults to 60 seconds —\n * long enough for a user to read + approve a signing prompt in the parent.\n */\n requestTimeoutMs?: number;\n};\n\nconst DEFAULT_REQUEST_TIMEOUT_MS = 60_000;\n\n/**\n * Detect iframe execution context. When this returns `false` the bridge\n * connector should not be installed and the host app should fall back to its\n * normal wallet config (ConnectKit + injected/walletConnect).\n *\n * `window.parent !== window` is the most reliable signal that works across\n * sandbox-iframe contexts where direct property access to parent throws.\n */\nexport function isRunningInIframe(): boolean {\n if (typeof window === 'undefined') return false;\n try {\n return window.parent !== undefined && window.parent !== window;\n } catch {\n // Cross-origin read of `window.parent` shouldn't throw, but be defensive.\n return true;\n }\n}\n\n/**\n * EIP-1193 provider backed by the Tangle Cloud iframe protocol. One instance\n * lives per iframe app; the wagmi connector owns the singleton.\n */\nexport class ParentBridgeProvider {\n private listeners = new Map<EventName, Set<Listener>>();\n private pending = new Map<string, PendingRequest<unknown>>();\n private cachedAccount: Address | null = null;\n private cachedChainId: number | null = null;\n private handshakeAcked = false;\n private handshakeWaiters: Array<() => void> = [];\n private installed = false;\n\n constructor(private readonly options: ParentBridgeOptions) {}\n\n /**\n * Wire up the global message listener and send the initial handshake.\n * Idempotent — safe to call repeatedly during reconnect attempts.\n */\n install(): void {\n if (this.installed || typeof window === 'undefined') return;\n this.installed = true;\n window.addEventListener('message', this.handleParentMessage);\n this.postToParent({\n kind: 'tangle.app.handshake',\n appId: this.options.appId,\n version: TANGLE_IFRAME_PROTOCOL_VERSION,\n });\n }\n\n uninstall(): void {\n if (!this.installed || typeof window === 'undefined') return;\n this.installed = false;\n window.removeEventListener('message', this.handleParentMessage);\n // Reject every pending request so callers don't hang forever.\n for (const [, pending] of this.pending) {\n pending.reject(new Error('Parent bridge uninstalled'));\n }\n this.pending.clear();\n }\n\n // ── EIP-1193 surface ────────────────────────────────────────────────────\n\n async request(req: { method: string; params?: unknown[] }): Promise<unknown> {\n const method = req.method;\n const params = (req.params ?? []) as unknown[];\n switch (method) {\n case 'eth_chainId': {\n await this.ensureBootstrapped();\n return this.cachedChainId !== null ? `0x${this.cachedChainId.toString(16)}` : '0x0';\n }\n case 'eth_accounts':\n case 'eth_requestAccounts': {\n await this.ensureBootstrapped();\n return this.cachedAccount !== null ? [this.cachedAccount] : [];\n }\n case 'personal_sign': {\n const [message, _signer] = params as [string, Address];\n return this.requestSignMessage(message);\n }\n case 'eth_signTypedData_v4': {\n // The current protocol doesn't carry typed-data — surface a clear\n // error rather than silently producing a personal_sign. Publishers\n // that need typed-data signing should upgrade the protocol.\n throw bridgeError(\n 4200,\n 'eth_signTypedData_v4 is not supported by the parent-bridge protocol yet.',\n );\n }\n case 'eth_sendTransaction': {\n const [tx] = params as [\n { to?: Address; data?: Hex; value?: Hex | string; chainId?: Hex | number },\n ];\n if (!tx?.to || !tx.data) {\n throw bridgeError(-32602, 'eth_sendTransaction requires `to` and `data`.');\n }\n return this.requestSignTransaction(tx);\n }\n case 'wallet_switchEthereumChain': {\n const [{ chainId }] = params as [{ chainId: Hex }];\n const numeric = Number.parseInt(chainId, 16);\n if (!Number.isFinite(numeric) || numeric <= 0) {\n throw bridgeError(-32602, `Invalid chainId: ${chainId}`);\n }\n await this.requestSwitchChain(numeric);\n return null;\n }\n case 'wallet_addEthereumChain': {\n // The parent owns the chain registry; iframes can't add chains the\n // dapp doesn't already know about.\n throw bridgeError(\n 4200,\n 'wallet_addEthereumChain is not supported through the parent bridge.',\n );\n }\n default:\n throw bridgeError(4200, `Method ${method} not supported by parent bridge.`);\n }\n }\n\n on(event: EventName, listener: Listener): void {\n const set = this.listeners.get(event) ?? new Set();\n set.add(listener);\n this.listeners.set(event, set);\n }\n\n removeListener(event: EventName, listener: Listener): void {\n this.listeners.get(event)?.delete(listener);\n }\n\n // ── Internal: dispatch + book-keeping ───────────────────────────────────\n\n private postToParent(message: object): void {\n if (typeof window === 'undefined') return;\n try {\n window.parent.postMessage(message, this.options.parentOrigin);\n } catch {\n // Cross-origin / sandboxed; parent.postMessage shouldn't actually throw\n // but be defensive against future browser changes.\n }\n }\n\n private handleParentMessage = (event: MessageEvent): void => {\n // Origin gate first; never parse untrusted payloads.\n if (event.origin !== this.options.parentOrigin) return;\n const data = event.data;\n if (typeof data !== 'object' || data === null) return;\n const message = data as ParentMessage;\n switch (message.kind) {\n case 'tangle.app.handshakeAck':\n this.handshakeAcked = true;\n for (const resolve of this.handshakeWaiters) resolve();\n this.handshakeWaiters = [];\n // After ack, ask for the current account so cached state reflects\n // reality before any consumer queries. Fire-and-forget — explicit\n // calls (`eth_accounts`, etc.) await their own request.\n this.sendReadAccount().catch(() => {\n // The first read commonly races with bridge teardown in tests\n // and isn't user-facing; swallow rather than producing unhandled\n // rejections. Subsequent `eth_accounts` calls retry on demand.\n });\n return;\n case 'tangle.app.readAccountResult':\n this.resolvePending(message);\n if (message.ok) {\n this.updateAccount(\n message.data.account === NO_WALLET_ADDRESS\n ? null\n : message.data.account,\n );\n this.updateChainId(message.data.chainId);\n }\n return;\n case 'tangle.app.switchChainResult':\n this.resolvePending(message);\n if (message.ok) this.updateChainId(message.data.chainId);\n return;\n case 'tangle.app.signMessageResult':\n case 'tangle.app.signTransactionResult':\n this.resolvePending(message);\n return;\n case 'tangle.app.accountChanged':\n this.updateAccount(message.account);\n return;\n case 'tangle.app.chainChanged':\n this.updateChainId(message.chainId);\n return;\n }\n };\n\n private sendReadAccount(): Promise<{ account: Address; chainId: number }> {\n return this.dispatch({\n kind: 'tangle.app.readAccount',\n expectedKind: 'tangle.app.readAccountResult',\n }) as Promise<{ account: Address; chainId: number }>;\n }\n\n private requestSignMessage(message: string): Promise<Hex> {\n const chainId = this.cachedChainId ?? 1;\n return this.dispatch({\n kind: 'tangle.app.signMessage',\n expectedKind: 'tangle.app.signMessageResult',\n payload: { chainId, message },\n }).then((data) => (data as { signature: Hex }).signature);\n }\n\n private requestSignTransaction(tx: {\n to?: Address;\n data?: Hex;\n value?: Hex | string;\n }): Promise<Hex> {\n const chainId = this.cachedChainId ?? 1;\n const value =\n typeof tx.value === 'string' && tx.value.startsWith('0x')\n ? BigInt(tx.value).toString(10)\n : typeof tx.value === 'string'\n ? tx.value\n : undefined;\n return this.dispatch({\n kind: 'tangle.app.signTransaction',\n expectedKind: 'tangle.app.signTransactionResult',\n payload: {\n chainId,\n to: tx.to as Address,\n data: tx.data as Hex,\n ...(value !== undefined ? { value } : {}),\n },\n }).then((data) => (data as { txHash: Hex }).txHash);\n }\n\n private requestSwitchChain(chainId: number): Promise<number> {\n return this.dispatch({\n kind: 'tangle.app.switchChain',\n expectedKind: 'tangle.app.switchChainResult',\n payload: { chainId },\n }).then((data) => (data as { chainId: number }).chainId);\n }\n\n private async dispatch(req: {\n kind: 'tangle.app.readAccount' | 'tangle.app.switchChain' | 'tangle.app.signMessage' | 'tangle.app.signTransaction';\n expectedKind: ParentMessage['kind'];\n payload?: Record<string, unknown>;\n }): Promise<unknown> {\n await this.ensureBootstrapped();\n const correlationId = makeCorrelationId(req.kind);\n const timeout = this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;\n return new Promise<unknown>((resolve, reject) => {\n const timer = window.setTimeout(() => {\n this.pending.delete(correlationId);\n reject(bridgeError(4900, `Parent did not respond to ${req.kind} within ${timeout}ms`));\n }, timeout);\n this.pending.set(correlationId, {\n resolve: (v) => {\n window.clearTimeout(timer);\n resolve(v);\n },\n reject: (e) => {\n window.clearTimeout(timer);\n reject(e);\n },\n expectedKind: req.expectedKind,\n });\n this.postToParent({\n kind: req.kind,\n correlationId,\n ...(req.payload ?? {}),\n });\n });\n }\n\n private resolvePending(message: Extract<ParentMessage, { correlationId: string }>): void {\n const entry = this.pending.get(message.correlationId);\n if (!entry) return;\n this.pending.delete(message.correlationId);\n if (entry.expectedKind !== message.kind) {\n entry.reject(\n bridgeError(\n -32000,\n `Parent replied with ${message.kind} but ${entry.expectedKind} was expected`,\n ),\n );\n return;\n }\n if (message.ok) {\n entry.resolve(message.data);\n } else {\n entry.reject(bridgeError(4001, message.error));\n }\n }\n\n private async ensureBootstrapped(): Promise<void> {\n if (this.handshakeAcked) return;\n this.install();\n await new Promise<void>((resolve) => {\n this.handshakeWaiters.push(resolve);\n // Re-send handshake every 500ms while we wait — covers a parent that\n // mounted after the iframe and missed the initial post.\n const retry = window.setInterval(() => {\n if (this.handshakeAcked) {\n window.clearInterval(retry);\n return;\n }\n this.postToParent({\n kind: 'tangle.app.handshake',\n appId: this.options.appId,\n version: TANGLE_IFRAME_PROTOCOL_VERSION,\n });\n }, 500);\n // Safety stop — handshake won't be re-attempted indefinitely.\n window.setTimeout(() => window.clearInterval(retry), 10_000);\n });\n }\n\n private updateAccount(next: Address | null): void {\n if (this.cachedAccount === next) return;\n const prev = this.cachedAccount;\n this.cachedAccount = next;\n if (next === null && prev !== null) {\n this.emit('disconnect');\n this.emit('accountsChanged', []);\n } else if (next !== null) {\n this.emit('accountsChanged', [next]);\n if (prev === null) {\n this.emit('connect', { chainId: this.cachedChainId ?? 0 });\n }\n }\n }\n\n private updateChainId(next: number): void {\n if (this.cachedChainId === next) return;\n this.cachedChainId = next;\n this.emit('chainChanged', `0x${next.toString(16)}`);\n }\n\n private emit(event: EventName, ...args: unknown[]): void {\n const set = this.listeners.get(event);\n if (!set) return;\n for (const listener of [...set]) {\n try {\n listener(...args);\n } catch {\n // Listener bugs shouldn't break the bridge.\n }\n }\n }\n\n // ── Test seams ──────────────────────────────────────────────────────────\n\n /** Visible for tests + the connector's `getAccounts()` shortcut. */\n getCachedAccount(): Address | null {\n return this.cachedAccount;\n }\n /** Visible for tests + the connector's `getChainId()` shortcut. */\n getCachedChainId(): number | null {\n return this.cachedChainId;\n }\n}\n\nfunction bridgeError(code: number, message: string): Error {\n const err = new Error(message) as Error & { code?: number };\n err.code = code;\n return err;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/blueprint-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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": {
|
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
"url": "https://github.com/tangle-network/blueprint-ui/issues"
|
|
13
13
|
},
|
|
14
14
|
"type": "module",
|
|
15
|
-
"main": "./
|
|
16
|
-
"types": "./
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"sideEffects": [
|
|
18
|
+
"**/*.css"
|
|
19
|
+
],
|
|
17
20
|
"files": [
|
|
21
|
+
"dist",
|
|
18
22
|
"src",
|
|
19
23
|
"README.md",
|
|
20
24
|
"package.json",
|
|
@@ -24,15 +28,31 @@
|
|
|
24
28
|
"access": "public"
|
|
25
29
|
},
|
|
26
30
|
"exports": {
|
|
27
|
-
".":
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
".": {
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./components": {
|
|
37
|
+
"import": "./dist/components.js",
|
|
38
|
+
"types": "./dist/components.d.ts",
|
|
39
|
+
"default": "./dist/components.js"
|
|
40
|
+
},
|
|
41
|
+
"./preset": {
|
|
42
|
+
"import": "./dist/preset.js",
|
|
43
|
+
"types": "./dist/preset.d.ts",
|
|
44
|
+
"default": "./dist/preset.js"
|
|
45
|
+
},
|
|
46
|
+
"./styles.css": "./dist/styles.css"
|
|
30
47
|
},
|
|
31
48
|
"scripts": {
|
|
32
|
-
"
|
|
49
|
+
"build": "tsup && node scripts/build-styles.mjs",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"prepack": "npm run build"
|
|
33
54
|
},
|
|
34
55
|
"peerDependencies": {
|
|
35
|
-
"@tanstack/react-query": "^5.0.0",
|
|
36
56
|
"@nanostores/react": "^0.7.0",
|
|
37
57
|
"@radix-ui/react-dialog": "^1.1.0",
|
|
38
58
|
"@radix-ui/react-select": "^2.1.0",
|
|
@@ -40,6 +60,7 @@
|
|
|
40
60
|
"@radix-ui/react-slot": "^1.2.0",
|
|
41
61
|
"@radix-ui/react-tabs": "^1.1.0",
|
|
42
62
|
"@radix-ui/react-tooltip": "^1.2.0",
|
|
63
|
+
"@tanstack/react-query": "^5.0.0",
|
|
43
64
|
"blo": "^2.0.0",
|
|
44
65
|
"class-variance-authority": "^0.7.0",
|
|
45
66
|
"clsx": "^2.1.0",
|
|
@@ -55,7 +76,7 @@
|
|
|
55
76
|
},
|
|
56
77
|
"peerDependenciesMeta": {},
|
|
57
78
|
"devDependencies": {
|
|
58
|
-
"@
|
|
79
|
+
"@iconify-json/ph": "^1.2.2",
|
|
59
80
|
"@nanostores/react": "^1.0.0",
|
|
60
81
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
61
82
|
"@radix-ui/react-select": "^2.2.6",
|
|
@@ -63,20 +84,29 @@
|
|
|
63
84
|
"@radix-ui/react-slot": "^1.2.4",
|
|
64
85
|
"@radix-ui/react-tabs": "^1.1.13",
|
|
65
86
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
87
|
+
"@tanstack/react-query": "^5.90.16",
|
|
88
|
+
"@testing-library/dom": "^10.4.1",
|
|
89
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
90
|
+
"@testing-library/react": "^16.3.2",
|
|
66
91
|
"@types/react": "18.3.1",
|
|
67
92
|
"@types/react-dom": "18.3.1",
|
|
68
93
|
"blo": "^2.0.0",
|
|
69
94
|
"class-variance-authority": "^0.7.1",
|
|
70
95
|
"clsx": "^2.1.1",
|
|
71
96
|
"framer-motion": "^12.34.3",
|
|
97
|
+
"jsdom": "^29.1.1",
|
|
72
98
|
"nanostores": "^1.1.0",
|
|
73
99
|
"react": "^19.2.4",
|
|
74
100
|
"react-dom": "^19.2.4",
|
|
75
101
|
"react-router": "^7.13.0",
|
|
76
102
|
"sonner": "^2.0.7",
|
|
77
103
|
"tailwind-merge": "^3.5.0",
|
|
104
|
+
"tsup": "^8.5.0",
|
|
78
105
|
"typescript": "^5.5.2",
|
|
106
|
+
"unocss": "^66.5.4",
|
|
107
|
+
"unocss-preset-animations": "^1.1.1",
|
|
79
108
|
"viem": "^2.46.2",
|
|
109
|
+
"vitest": "^4.1.7",
|
|
80
110
|
"wagmi": "^3.5.0"
|
|
81
111
|
}
|
|
82
112
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useStore } from '@nanostores/react';
|
|
2
|
+
import { useAccount } from 'wagmi';
|
|
3
|
+
import type { Address } from 'viem';
|
|
2
4
|
import type { JobDefinition } from '../../blueprints/registry';
|
|
3
5
|
import { useJobForm } from '../../hooks/useJobForm';
|
|
4
6
|
import { useSubmitJob } from '../../hooks/useSubmitJob';
|
|
@@ -35,18 +37,24 @@ export function JobExecutionDialog({
|
|
|
35
37
|
onSuccess,
|
|
36
38
|
}: JobExecutionDialogProps) {
|
|
37
39
|
const infra = useStore(infraStore);
|
|
40
|
+
const { address } = useAccount();
|
|
38
41
|
const { values, errors, onChange, validate, reset } = useJobForm(job);
|
|
39
42
|
const { submitJob, status, error: txError, txHash, reset: resetTx } = useSubmitJob();
|
|
40
43
|
|
|
41
|
-
// Per-job RFQ: fetch price from operator before submission
|
|
44
|
+
// Per-job RFQ: fetch price from operator before submission.
|
|
45
|
+
// tnt-core v0.13.0 binds quotes to the future caller — only run the hook
|
|
46
|
+
// once a wallet address is available.
|
|
42
47
|
const operatorRpcUrl = infra.serviceInfo?.operators?.[0]?.rpcAddress;
|
|
43
48
|
const blueprintId = BigInt(infra.blueprintId || '0');
|
|
49
|
+
const ZERO: Address = '0x0000000000000000000000000000000000000000';
|
|
50
|
+
const requester: Address = (address ?? ZERO) as Address;
|
|
44
51
|
const { quote, isLoading: priceLoading, isSolvingPow, formattedPrice, error: priceError } = useJobPrice(
|
|
45
52
|
operatorRpcUrl,
|
|
46
53
|
serviceId,
|
|
47
54
|
job.id,
|
|
48
55
|
blueprintId,
|
|
49
|
-
open && !!operatorRpcUrl && serviceId > 0n,
|
|
56
|
+
open && !!operatorRpcUrl && serviceId > 0n && !!address,
|
|
57
|
+
requester,
|
|
50
58
|
);
|
|
51
59
|
|
|
52
60
|
// Fallback price from multiplier (base rate = 0.001 TNT = 1e15 wei)
|
package/src/components.ts
CHANGED
|
@@ -52,3 +52,6 @@ export type { FormSection } from './components/forms/BlueprintJobForm';
|
|
|
52
52
|
export { BlueprintJobForm } from './components/forms/BlueprintJobForm';
|
|
53
53
|
export { FormSummary } from './components/forms/FormSummary';
|
|
54
54
|
export { JobExecutionDialog } from './components/forms/JobExecutionDialog';
|
|
55
|
+
|
|
56
|
+
// ── Blueprint Host ──
|
|
57
|
+
export { BlueprintHostHero, BlueprintHostPanel } from './host';
|
package/src/contracts/abi.ts
CHANGED
|
@@ -98,11 +98,15 @@ export const tangleServicesAbi = [
|
|
|
98
98
|
name: 'details',
|
|
99
99
|
type: 'tuple',
|
|
100
100
|
components: [
|
|
101
|
+
// tnt-core v0.13.0: `requester` is the first field of QuoteDetails.
|
|
102
|
+
// The contract enforces `requester == msg.sender` and rejects address(0).
|
|
103
|
+
{ name: 'requester', type: 'address' },
|
|
101
104
|
{ name: 'blueprintId', type: 'uint64' },
|
|
102
105
|
{ name: 'ttlBlocks', type: 'uint64' },
|
|
103
106
|
{ name: 'totalCost', type: 'uint256' },
|
|
104
107
|
{ name: 'timestamp', type: 'uint64' },
|
|
105
108
|
{ name: 'expiry', type: 'uint64' },
|
|
109
|
+
{ name: 'confidentiality', type: 'uint8' },
|
|
106
110
|
{
|
|
107
111
|
name: 'securityCommitments',
|
|
108
112
|
type: 'tuple[]',
|
|
@@ -118,6 +122,14 @@ export const tangleServicesAbi = [
|
|
|
118
122
|
{ name: 'exposureBps', type: 'uint16' },
|
|
119
123
|
],
|
|
120
124
|
},
|
|
125
|
+
{
|
|
126
|
+
name: 'resourceCommitments',
|
|
127
|
+
type: 'tuple[]',
|
|
128
|
+
components: [
|
|
129
|
+
{ name: 'kind', type: 'uint8' },
|
|
130
|
+
{ name: 'count', type: 'uint64' },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
121
133
|
],
|
|
122
134
|
},
|
|
123
135
|
{ name: 'signature', type: 'bytes' },
|
package/src/contracts/chains.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { defineChain } from 'viem';
|
|
2
2
|
import { mainnet } from 'viem/chains';
|
|
3
3
|
import type { Address, Chain } from 'viem';
|
|
4
|
+
import { getEnvVar, isDevEnv } from '../utils/env';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Resolve RPC URL for the current environment.
|
|
7
8
|
* Handles local dev (hostname swap), Vite dev proxy, and remote access.
|
|
8
9
|
*/
|
|
9
10
|
export function resolveRpcUrl(envUrl?: string): string {
|
|
10
|
-
const configured = envUrl ??
|
|
11
|
+
const configured = envUrl ?? getEnvVar('VITE_RPC_URL') ?? 'http://localhost:8545';
|
|
11
12
|
if (typeof window === 'undefined') return configured;
|
|
12
13
|
try {
|
|
13
14
|
const rpc = new URL(configured);
|
|
@@ -15,7 +16,7 @@ export function resolveRpcUrl(envUrl?: string): string {
|
|
|
15
16
|
const pageHost = window.location.hostname;
|
|
16
17
|
const isLocalPage = pageHost === '127.0.0.1' || pageHost === 'localhost';
|
|
17
18
|
// Dev-mode proxy for LAN access to local RPC
|
|
18
|
-
if (isLocalRpc && !isLocalPage &&
|
|
19
|
+
if (isLocalRpc && !isLocalPage && isDevEnv()) {
|
|
19
20
|
return `${window.location.origin}/rpc-proxy`;
|
|
20
21
|
}
|
|
21
22
|
// Non-dev LAN access: swap hostname
|
|
@@ -37,7 +38,7 @@ export interface LocalChainOptions {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export function createTangleLocalChain(options: LocalChainOptions = {}) {
|
|
40
|
-
const chainId = options.chainId ?? Number(
|
|
41
|
+
const chainId = options.chainId ?? Number(getEnvVar('VITE_CHAIN_ID') ?? 31337);
|
|
41
42
|
const localRpcUrl = resolveRpcUrl(options.rpcUrl);
|
|
42
43
|
|
|
43
44
|
return defineChain({
|
|
@@ -3,8 +3,9 @@ import type { PublicClient } from 'viem';
|
|
|
3
3
|
import { atom } from 'nanostores';
|
|
4
4
|
import { getNetworks, tangleLocal, type CoreAddresses } from './chains';
|
|
5
5
|
import { persistedAtom } from '../stores/persistedAtom';
|
|
6
|
+
import { getEnvVar } from '../utils/env';
|
|
6
7
|
|
|
7
|
-
const defaultChainId = Number(
|
|
8
|
+
const defaultChainId = Number(getEnvVar('VITE_CHAIN_ID') ?? tangleLocal.id);
|
|
8
9
|
|
|
9
10
|
export const selectedChainIdStore = persistedAtom<number>({
|
|
10
11
|
key: 'bp_selected_chain',
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import { useJobPrice, useJobPrices, assertRequester } from './useJobPrice';
|
|
4
|
+
|
|
5
|
+
vi.mock('./useQuotes', async () => {
|
|
6
|
+
const actual = await vi.importActual('./useQuotes');
|
|
7
|
+
return {
|
|
8
|
+
...(actual as any),
|
|
9
|
+
solvePoW: vi.fn().mockResolvedValue({ proof: new Uint8Array([1, 2, 3]) }),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const VALID_REQUESTER = '0x1234567890123456789012345678901234567890';
|
|
14
|
+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
|
|
15
|
+
const ALT_REQUESTER = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd';
|
|
16
|
+
|
|
17
|
+
describe('assertRequester guard', () => {
|
|
18
|
+
it('throws when enabled=true and requester is zero address', () => {
|
|
19
|
+
expect(() => assertRequester(ZERO_ADDRESS, 'useJobPrice', true)).toThrow(
|
|
20
|
+
/requester.*required.*non-zero address/i,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('does not throw when enabled=false and requester is zero address', () => {
|
|
25
|
+
expect(() => assertRequester(ZERO_ADDRESS, 'useJobPrice', false)).not.toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('throws when enabled=true and requester is empty string', () => {
|
|
29
|
+
expect(() => assertRequester('' as `0x${string}`, 'useJobPrice', true)).toThrow(
|
|
30
|
+
/requester.*required.*non-zero address/i,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not throw when enabled=true and requester is valid', () => {
|
|
35
|
+
expect(() => assertRequester(VALID_REQUESTER as `0x${string}`, 'useJobPrice', true)).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('useJobPrice requester integration', () => {
|
|
40
|
+
const defaultFetchResponse = {
|
|
41
|
+
ok: true,
|
|
42
|
+
json: async () => ({
|
|
43
|
+
service_id: '1',
|
|
44
|
+
job_index: 0,
|
|
45
|
+
price: '1000',
|
|
46
|
+
timestamp: '1234567890',
|
|
47
|
+
expiry: '1234567900',
|
|
48
|
+
signature: '0xdeadbeef',
|
|
49
|
+
operator: '0xoperator',
|
|
50
|
+
}),
|
|
51
|
+
} as Response;
|
|
52
|
+
|
|
53
|
+
it('includes requester in the POST body', async () => {
|
|
54
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(defaultFetchResponse);
|
|
55
|
+
|
|
56
|
+
renderHook(() =>
|
|
57
|
+
useJobPrice('http://localhost:8080', 1n, 0, 1n, true, VALID_REQUESTER as `0x${string}`),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await waitFor(() => {
|
|
61
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
65
|
+
const body = JSON.parse((init as RequestInit).body as string);
|
|
66
|
+
expect(body.requester).toBe(VALID_REQUESTER);
|
|
67
|
+
|
|
68
|
+
fetchSpy.mockRestore();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('falls back to input requester when response lacks requester', async () => {
|
|
72
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(defaultFetchResponse);
|
|
73
|
+
|
|
74
|
+
const { result } = renderHook(() =>
|
|
75
|
+
useJobPrice('http://localhost:8080', 1n, 0, 1n, true, VALID_REQUESTER as `0x${string}`),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(result.current.quote).not.toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.current.quote?.requester).toBe(VALID_REQUESTER);
|
|
83
|
+
|
|
84
|
+
fetchSpy.mockRestore();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('uses response requester when present', async () => {
|
|
88
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
89
|
+
ok: true,
|
|
90
|
+
json: async () => ({
|
|
91
|
+
service_id: '1',
|
|
92
|
+
job_index: 0,
|
|
93
|
+
price: '1000',
|
|
94
|
+
timestamp: '1234567890',
|
|
95
|
+
expiry: '1234567900',
|
|
96
|
+
signature: '0xdeadbeef',
|
|
97
|
+
operator: '0xoperator',
|
|
98
|
+
requester: ALT_REQUESTER,
|
|
99
|
+
}),
|
|
100
|
+
} as Response);
|
|
101
|
+
|
|
102
|
+
const { result } = renderHook(() =>
|
|
103
|
+
useJobPrice('http://localhost:8080', 1n, 0, 1n, true, VALID_REQUESTER as `0x${string}`),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(result.current.quote).not.toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(result.current.quote?.requester).toBe(ALT_REQUESTER);
|
|
111
|
+
|
|
112
|
+
fetchSpy.mockRestore();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('useJobPrices requester integration', () => {
|
|
117
|
+
const defaultFetchResponse = {
|
|
118
|
+
ok: true,
|
|
119
|
+
json: async () => ({
|
|
120
|
+
service_id: '1',
|
|
121
|
+
job_index: 0,
|
|
122
|
+
price: '1000',
|
|
123
|
+
timestamp: '1234567890',
|
|
124
|
+
expiry: '1234567900',
|
|
125
|
+
signature: '0xdeadbeef',
|
|
126
|
+
operator: '0xoperator',
|
|
127
|
+
}),
|
|
128
|
+
} as Response;
|
|
129
|
+
|
|
130
|
+
const stableJobIndexes = [{ index: 0, name: 'test', multiplier: 1 }];
|
|
131
|
+
|
|
132
|
+
it('includes requester in the POST body', async () => {
|
|
133
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(defaultFetchResponse);
|
|
134
|
+
|
|
135
|
+
renderHook(() =>
|
|
136
|
+
useJobPrices(
|
|
137
|
+
'http://localhost:8080',
|
|
138
|
+
1n,
|
|
139
|
+
1n,
|
|
140
|
+
stableJobIndexes,
|
|
141
|
+
true,
|
|
142
|
+
VALID_REQUESTER as `0x${string}`,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await waitFor(() => {
|
|
147
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const [, init] = fetchSpy.mock.calls[0];
|
|
151
|
+
const body = JSON.parse((init as RequestInit).body as string);
|
|
152
|
+
expect(body.requester).toBe(VALID_REQUESTER);
|
|
153
|
+
|
|
154
|
+
fetchSpy.mockRestore();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('falls back to input requester when response lacks requester', async () => {
|
|
158
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(defaultFetchResponse);
|
|
159
|
+
|
|
160
|
+
const { result } = renderHook(() =>
|
|
161
|
+
useJobPrices(
|
|
162
|
+
'http://localhost:8080',
|
|
163
|
+
1n,
|
|
164
|
+
1n,
|
|
165
|
+
stableJobIndexes,
|
|
166
|
+
true,
|
|
167
|
+
VALID_REQUESTER as `0x${string}`,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
expect(result.current.prices.length).toBeGreaterThan(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(result.current.prices[0].quote?.requester).toBe(VALID_REQUESTER);
|
|
176
|
+
|
|
177
|
+
fetchSpy.mockRestore();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('uses response requester when present', async () => {
|
|
181
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
182
|
+
ok: true,
|
|
183
|
+
json: async () => ({
|
|
184
|
+
service_id: '1',
|
|
185
|
+
job_index: 0,
|
|
186
|
+
price: '1000',
|
|
187
|
+
timestamp: '1234567890',
|
|
188
|
+
expiry: '1234567900',
|
|
189
|
+
signature: '0xdeadbeef',
|
|
190
|
+
operator: '0xoperator',
|
|
191
|
+
requester: ALT_REQUESTER,
|
|
192
|
+
}),
|
|
193
|
+
} as Response);
|
|
194
|
+
|
|
195
|
+
const { result } = renderHook(() =>
|
|
196
|
+
useJobPrices(
|
|
197
|
+
'http://localhost:8080',
|
|
198
|
+
1n,
|
|
199
|
+
1n,
|
|
200
|
+
stableJobIndexes,
|
|
201
|
+
true,
|
|
202
|
+
VALID_REQUESTER as `0x${string}`,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await waitFor(() => {
|
|
207
|
+
expect(result.current.prices.length).toBeGreaterThan(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(result.current.prices[0].quote?.requester).toBe(ALT_REQUESTER);
|
|
211
|
+
|
|
212
|
+
fetchSpy.mockRestore();
|
|
213
|
+
});
|
|
214
|
+
});
|
package/src/hooks/useJobPrice.ts
CHANGED
|
@@ -18,6 +18,12 @@ import { solvePoW, formatCost } from './useQuotes';
|
|
|
18
18
|
// ── Types ──
|
|
19
19
|
|
|
20
20
|
export interface JobQuote {
|
|
21
|
+
/**
|
|
22
|
+
* Address of the account that will submit `submitJobFromQuote`.
|
|
23
|
+
* Required since tnt-core v0.13.0 — the contract enforces
|
|
24
|
+
* `requester == msg.sender` and rejects `address(0)` (no wildcard quotes).
|
|
25
|
+
*/
|
|
26
|
+
requester: Address;
|
|
21
27
|
serviceId: bigint;
|
|
22
28
|
jobIndex: number;
|
|
23
29
|
price: bigint;
|
|
@@ -59,13 +65,47 @@ function resolveOperatorRpc(raw: string): string {
|
|
|
59
65
|
|
|
60
66
|
// ── Hook ──
|
|
61
67
|
|
|
68
|
+
const ZERO_ADDRESS_LOWER = '0x0000000000000000000000000000000000000000';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Defensive guard: throws when a consumer enables fetching but has not
|
|
72
|
+
* supplied a non-zero `requester`. Skipped when `enabled` is false so
|
|
73
|
+
* components can pass a derived value (e.g. `address ?? ZERO`) before the
|
|
74
|
+
* wallet has connected.
|
|
75
|
+
*/
|
|
76
|
+
export function assertRequester(requester: Address, hookName: string, enabled: boolean): void {
|
|
77
|
+
if (!enabled) return;
|
|
78
|
+
if (!requester || requester.toLowerCase() === ZERO_ADDRESS_LOWER) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${hookName}: \`requester\` is required and must be a non-zero address when \`enabled=true\`. ` +
|
|
81
|
+
'Pass `useAccount().address` from wagmi. tnt-core v0.13.0 contracts ' +
|
|
82
|
+
'reject quotes whose requester is address(0) or != msg.sender.',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fetches a signed `JobQuote` for `submitJobFromQuote`.
|
|
89
|
+
*
|
|
90
|
+
* @param operatorRpcUrl Operator RPC base URL (e.g. from `getOperatorPreferences`).
|
|
91
|
+
* @param serviceId Target service id.
|
|
92
|
+
* @param jobIndex Job index within the blueprint.
|
|
93
|
+
* @param blueprintId Blueprint id (used for the PoW challenge domain).
|
|
94
|
+
* @param enabled Gate to disable fetching while inputs settle.
|
|
95
|
+
* @param requester Address that will submit `submitJobFromQuote` —
|
|
96
|
+
* must equal `msg.sender` at submission time. Required
|
|
97
|
+
* since tnt-core v0.13.0; the contract rejects
|
|
98
|
+
* `address(0)` and any mismatch.
|
|
99
|
+
*/
|
|
62
100
|
export function useJobPrice(
|
|
63
101
|
operatorRpcUrl: string | undefined,
|
|
64
102
|
serviceId: bigint,
|
|
65
103
|
jobIndex: number,
|
|
66
104
|
blueprintId: bigint,
|
|
67
105
|
enabled: boolean,
|
|
106
|
+
requester: Address,
|
|
68
107
|
): UseJobPriceResult {
|
|
108
|
+
assertRequester(requester, 'useJobPrice', enabled);
|
|
69
109
|
const [quote, setQuote] = useState<JobQuote | null>(null);
|
|
70
110
|
const [isLoading, setIsLoading] = useState(false);
|
|
71
111
|
const [isSolvingPow, setIsSolvingPow] = useState(false);
|
|
@@ -107,6 +147,9 @@ export function useJobPrice(
|
|
|
107
147
|
job_index: jobIndex,
|
|
108
148
|
proof_of_work: toHex(proof),
|
|
109
149
|
challenge_timestamp: String(timestamp),
|
|
150
|
+
// tnt-core v0.13.0: operators sign `requester` into JobQuoteDetails;
|
|
151
|
+
// the contract enforces `requester == msg.sender` on submission.
|
|
152
|
+
requester,
|
|
110
153
|
}),
|
|
111
154
|
signal: AbortSignal.timeout(10_000),
|
|
112
155
|
});
|
|
@@ -119,6 +162,7 @@ export function useJobPrice(
|
|
|
119
162
|
if (cancelledRef.current) return;
|
|
120
163
|
|
|
121
164
|
setQuote({
|
|
165
|
+
requester: ((data.requester as Address | undefined) ?? requester),
|
|
122
166
|
serviceId: BigInt(data.service_id ?? serviceId),
|
|
123
167
|
jobIndex: data.job_index ?? jobIndex,
|
|
124
168
|
price: BigInt(data.price ?? '0'),
|
|
@@ -143,7 +187,7 @@ export function useJobPrice(
|
|
|
143
187
|
return () => {
|
|
144
188
|
cancelledRef.current = true;
|
|
145
189
|
};
|
|
146
|
-
}, [operatorRpcUrl, serviceId, jobIndex, blueprintId, enabled, fetchKey]);
|
|
190
|
+
}, [operatorRpcUrl, serviceId, jobIndex, blueprintId, enabled, fetchKey, requester]);
|
|
147
191
|
|
|
148
192
|
const formattedPrice = quote ? formatCost(quote.price) : '--';
|
|
149
193
|
|
|
@@ -171,13 +215,20 @@ export interface UseJobPricesResult {
|
|
|
171
215
|
refetch: () => void;
|
|
172
216
|
}
|
|
173
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Batch counterpart to {@link useJobPrice}. Same `requester` contract: the
|
|
220
|
+
* caller must pass `useAccount().address`; the field is required since
|
|
221
|
+
* tnt-core v0.13.0.
|
|
222
|
+
*/
|
|
174
223
|
export function useJobPrices(
|
|
175
224
|
operatorRpcUrl: string | undefined,
|
|
176
225
|
serviceId: bigint,
|
|
177
226
|
blueprintId: bigint,
|
|
178
227
|
jobIndexes: { index: number; name: string; multiplier: number }[],
|
|
179
228
|
enabled: boolean,
|
|
229
|
+
requester: Address,
|
|
180
230
|
): UseJobPricesResult {
|
|
231
|
+
assertRequester(requester, 'useJobPrices', enabled);
|
|
181
232
|
const [prices, setPrices] = useState<JobPriceEntry[]>([]);
|
|
182
233
|
const [isLoading, setIsLoading] = useState(false);
|
|
183
234
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -214,6 +265,8 @@ export function useJobPrices(
|
|
|
214
265
|
job_index: job.index,
|
|
215
266
|
proof_of_work: toHex(proof),
|
|
216
267
|
challenge_timestamp: String(timestamp),
|
|
268
|
+
// tnt-core v0.13.0: see useJobPrice notes.
|
|
269
|
+
requester,
|
|
217
270
|
}),
|
|
218
271
|
signal: AbortSignal.timeout(10_000),
|
|
219
272
|
});
|
|
@@ -237,6 +290,7 @@ export function useJobPrices(
|
|
|
237
290
|
formattedPrice: formatCost(price),
|
|
238
291
|
mode: (data.mode ?? 'flat') as 'flat' | 'dynamic' | 'free',
|
|
239
292
|
quote: {
|
|
293
|
+
requester: ((data.requester as Address | undefined) ?? requester),
|
|
240
294
|
serviceId: BigInt(data.service_id ?? serviceId),
|
|
241
295
|
jobIndex: job.index,
|
|
242
296
|
price,
|
|
@@ -277,7 +331,7 @@ export function useJobPrices(
|
|
|
277
331
|
return () => {
|
|
278
332
|
cancelled = true;
|
|
279
333
|
};
|
|
280
|
-
}, [operatorRpcUrl, serviceId, blueprintId, jobIndexes, enabled, fetchKey]);
|
|
334
|
+
}, [operatorRpcUrl, serviceId, blueprintId, jobIndexes, enabled, fetchKey, requester]);
|
|
281
335
|
|
|
282
336
|
return { prices, isLoading, error, refetch };
|
|
283
337
|
}
|