0g-orbit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/cli/cli.d.ts +3 -0
  2. package/dist/cli/cli.d.ts.map +1 -0
  3. package/dist/cli/cli.js +59 -0
  4. package/dist/cli/cli.js.map +1 -0
  5. package/dist/cli/commands/account.d.ts +6 -0
  6. package/dist/cli/commands/account.d.ts.map +1 -0
  7. package/dist/cli/commands/account.js +23 -0
  8. package/dist/cli/commands/account.js.map +1 -0
  9. package/dist/cli/commands/inference.d.ts +15 -0
  10. package/dist/cli/commands/inference.d.ts.map +1 -0
  11. package/dist/cli/commands/inference.js +70 -0
  12. package/dist/cli/commands/inference.js.map +1 -0
  13. package/dist/cli/commands/init.d.ts +7 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -0
  15. package/dist/cli/commands/init.js +60 -0
  16. package/dist/cli/commands/init.js.map +1 -0
  17. package/dist/cli/commands/storage.d.ts +19 -0
  18. package/dist/cli/commands/storage.d.ts.map +1 -0
  19. package/dist/cli/commands/storage.js +62 -0
  20. package/dist/cli/commands/storage.js.map +1 -0
  21. package/dist/cli/utils.d.ts +4 -0
  22. package/dist/cli/utils.d.ts.map +1 -0
  23. package/dist/cli/utils.js +20 -0
  24. package/dist/cli/utils.js.map +1 -0
  25. package/dist/errors.d.ts +26 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/errors.js +51 -0
  28. package/dist/errors.js.map +1 -0
  29. package/dist/index.d.ts +10 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +11 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/inference.d.ts +17 -0
  34. package/dist/inference.d.ts.map +1 -0
  35. package/dist/inference.js +179 -0
  36. package/dist/inference.js.map +1 -0
  37. package/dist/networks.d.ts +16 -0
  38. package/dist/networks.d.ts.map +1 -0
  39. package/dist/networks.js +48 -0
  40. package/dist/networks.js.map +1 -0
  41. package/dist/orbit.d.ts +27 -0
  42. package/dist/orbit.d.ts.map +1 -0
  43. package/dist/orbit.js +108 -0
  44. package/dist/orbit.js.map +1 -0
  45. package/dist/retry.d.ts +23 -0
  46. package/dist/retry.d.ts.map +1 -0
  47. package/dist/retry.js +90 -0
  48. package/dist/retry.js.map +1 -0
  49. package/dist/storage.d.ts +26 -0
  50. package/dist/storage.d.ts.map +1 -0
  51. package/dist/storage.js +121 -0
  52. package/dist/storage.js.map +1 -0
  53. package/dist/types.d.ts +81 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +2 -0
  56. package/dist/types.js.map +1 -0
  57. package/examples/ai-chatbot/index.ts +74 -0
  58. package/examples/model-registry/index.ts +137 -0
  59. package/examples/quick-start/index.ts +65 -0
  60. package/package.json +42 -0
  61. package/packages/cli/package.json +30 -0
  62. package/packages/cli/src/cli.ts +69 -0
  63. package/packages/cli/src/commands/account.ts +29 -0
  64. package/packages/cli/src/commands/inference.ts +103 -0
  65. package/packages/cli/src/commands/init.ts +71 -0
  66. package/packages/cli/src/commands/storage.ts +91 -0
  67. package/packages/cli/src/utils.ts +21 -0
  68. package/packages/cli/tsconfig.json +8 -0
  69. package/packages/core/package.json +35 -0
  70. package/packages/core/src/errors.test.ts +99 -0
  71. package/packages/core/src/errors.ts +79 -0
  72. package/packages/core/src/index.ts +37 -0
  73. package/packages/core/src/inference.ts +256 -0
  74. package/packages/core/src/networks.test.ts +62 -0
  75. package/packages/core/src/networks.ts +62 -0
  76. package/packages/core/src/orbit.test.ts +153 -0
  77. package/packages/core/src/orbit.ts +159 -0
  78. package/packages/core/src/retry.test.ts +99 -0
  79. package/packages/core/src/retry.ts +99 -0
  80. package/packages/core/src/storage.test.ts +199 -0
  81. package/packages/core/src/storage.ts +158 -0
  82. package/packages/core/src/types.ts +85 -0
  83. package/packages/core/tsconfig.json +8 -0
  84. package/packages/core/vitest.config.ts +7 -0
  85. package/src/cli/cli.ts +69 -0
  86. package/src/cli/commands/account.ts +29 -0
  87. package/src/cli/commands/inference.ts +103 -0
  88. package/src/cli/commands/init.ts +71 -0
  89. package/src/cli/commands/storage.ts +91 -0
  90. package/src/cli/utils.ts +21 -0
  91. package/src/errors.test.ts +99 -0
  92. package/src/errors.ts +79 -0
  93. package/src/index.ts +37 -0
  94. package/src/inference.ts +256 -0
  95. package/src/networks.test.ts +62 -0
  96. package/src/networks.ts +62 -0
  97. package/src/orbit.test.ts +153 -0
  98. package/src/orbit.ts +159 -0
  99. package/src/retry.test.ts +99 -0
  100. package/src/retry.ts +99 -0
  101. package/src/storage.test.ts +199 -0
  102. package/src/storage.ts +158 -0
  103. package/src/types.ts +85 -0
  104. package/tsconfig.json +20 -0
  105. package/vitest.config.ts +7 -0
@@ -0,0 +1,256 @@
1
+ import { Wallet } from 'ethers'
2
+ import {
3
+ createZGComputeNetworkBroker,
4
+ type ZGComputeNetworkBroker,
5
+ } from '@0glabs/0g-serving-broker'
6
+ import type { NetworkConfig } from './networks.js'
7
+ import type { InferResult, InferOptions, ServiceInfo } from './types.js'
8
+ import { InferenceError, ProviderNotFoundError, TimeoutError } from './errors.js'
9
+ import { withRetry } from './retry.js'
10
+
11
+ const DEFAULT_LEDGER_DEPOSIT = 0.1 // 0.1 OG initial deposit
12
+ const AUTO_FUND_INTERVAL = 30_000 // 30 seconds
13
+ const DEFAULT_TIMEOUT = 30_000 // 30 seconds
14
+
15
+ interface ChatCompletionResponse {
16
+ id?: string
17
+ model?: string
18
+ choices?: Array<{ message?: { content?: string } }>
19
+ usage?: {
20
+ prompt_tokens?: number
21
+ completion_tokens?: number
22
+ total_tokens?: number
23
+ }
24
+ }
25
+
26
+ export class InferenceClient {
27
+ private broker: ZGComputeNetworkBroker | null = null
28
+ private wallet: Wallet
29
+ private network: NetworkConfig
30
+ private initialized = false
31
+
32
+ constructor(network: NetworkConfig, wallet: Wallet) {
33
+ this.network = network
34
+ this.wallet = wallet
35
+ }
36
+
37
+ private async ensureBroker(): Promise<ZGComputeNetworkBroker> {
38
+ if (this.broker && this.initialized) return this.broker
39
+
40
+ try {
41
+ // Cast wallet to avoid ESM/CJS ethers type mismatch
42
+ this.broker = await createZGComputeNetworkBroker(
43
+ this.wallet as any,
44
+ this.network.ledgerContractAddress,
45
+ this.network.inferenceContractAddress,
46
+ this.network.fineTuningContractAddress
47
+ )
48
+ this.initialized = true
49
+ return this.broker
50
+ } catch (err: unknown) {
51
+ const msg = err instanceof Error ? err.message : String(err)
52
+ throw new InferenceError(
53
+ `Failed to initialize compute broker: ${msg}`,
54
+ 'Check your network connection and wallet private key. The compute contracts may be temporarily unavailable.'
55
+ )
56
+ }
57
+ }
58
+
59
+ async listServices(): Promise<ServiceInfo[]> {
60
+ const broker = await this.ensureBroker()
61
+ return withRetry(
62
+ async () => {
63
+ try {
64
+ const services = await broker.inference.listService()
65
+ return services.map((s: any) => ({
66
+ provider: s.provider ?? s.address ?? '',
67
+ model: s.model ?? '',
68
+ url: s.url ?? '',
69
+ inputPrice: BigInt(s.inputPrice ?? 0),
70
+ outputPrice: BigInt(s.outputPrice ?? 0),
71
+ verifiable: Boolean(s.verifiability ?? s.verifiable),
72
+ }))
73
+ } catch (err: unknown) {
74
+ const msg = err instanceof Error ? err.message : String(err)
75
+ throw new InferenceError(
76
+ `Failed to list services: ${msg}`,
77
+ 'The inference contract may be temporarily unavailable. Check your network connection.'
78
+ )
79
+ }
80
+ },
81
+ { maxAttempts: 3 }
82
+ )
83
+ }
84
+
85
+ async infer(
86
+ model: string,
87
+ options: InferOptions
88
+ ): Promise<InferResult> {
89
+ const broker = await this.ensureBroker()
90
+
91
+ // Find a provider for the requested model
92
+ const providerAddress = options.provider ?? await this.findProvider(model)
93
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT
94
+
95
+ try {
96
+ // Ensure ledger exists and has funds
97
+ await this.ensureLedgerFunded(broker)
98
+
99
+ // Start auto-funding for this provider
100
+ await broker.inference.startAutoFunding(providerAddress, {
101
+ interval: AUTO_FUND_INTERVAL,
102
+ })
103
+
104
+ // Get service metadata (endpoint + model name)
105
+ const { endpoint, model: providerModel } =
106
+ await broker.inference.getServiceMetadata(providerAddress)
107
+
108
+ // Build the request content for billing calculation
109
+ const content = options.messages.map((m) => m.content).join('\n')
110
+
111
+ // Get authenticated headers
112
+ const headers = await broker.inference.getRequestHeaders(
113
+ providerAddress,
114
+ content
115
+ )
116
+
117
+ // Make the OpenAI-compatible request with timeout + retry
118
+ const data = await withRetry(
119
+ () => this.fetchCompletion(endpoint, providerModel, options, headers, timeout),
120
+ {
121
+ maxAttempts: 2,
122
+ baseDelay: 2000,
123
+ isRetryable: (err) => {
124
+ // Don't retry timeouts (user already waited long enough)
125
+ if (err instanceof TimeoutError) return false
126
+ // Retry transient provider errors
127
+ if (err instanceof InferenceError) {
128
+ const msg = err.message
129
+ return msg.includes('502') || msg.includes('503') || msg.includes('504')
130
+ }
131
+ return false
132
+ },
133
+ }
134
+ )
135
+
136
+ // Extract chatID for TEE verification
137
+ const chatID = data._chatID
138
+
139
+ // Process response (caches fees + verifies TEE signature)
140
+ let verified: boolean | null = null
141
+ try {
142
+ verified = await broker.inference.processResponse(
143
+ providerAddress,
144
+ chatID,
145
+ data.usage ? JSON.stringify(data.usage) : undefined
146
+ )
147
+ } catch {
148
+ // Verification failure is non-fatal
149
+ verified = null
150
+ }
151
+
152
+ // Stop auto-funding
153
+ broker.inference.stopAutoFunding(providerAddress)
154
+
155
+ return {
156
+ content: data.choices?.[0]?.message?.content ?? '',
157
+ model: data.model ?? providerModel,
158
+ usage: data.usage
159
+ ? {
160
+ promptTokens: data.usage.prompt_tokens ?? 0,
161
+ completionTokens: data.usage.completion_tokens ?? 0,
162
+ totalTokens: data.usage.total_tokens ?? 0,
163
+ }
164
+ : undefined,
165
+ verified,
166
+ }
167
+ } catch (err) {
168
+ // Clean up auto-funding on error
169
+ broker.inference.stopAutoFunding(providerAddress)
170
+
171
+ if (err instanceof InferenceError || err instanceof TimeoutError || err instanceof ProviderNotFoundError) throw err
172
+ const msg = err instanceof Error ? err.message : String(err)
173
+ throw new InferenceError(`Inference failed: ${msg}`)
174
+ }
175
+ }
176
+
177
+ private async fetchCompletion(
178
+ endpoint: string,
179
+ providerModel: string,
180
+ options: InferOptions,
181
+ headers: Record<string, string> | object,
182
+ timeout: number
183
+ ): Promise<ChatCompletionResponse & { _chatID?: string }> {
184
+ const controller = new AbortController()
185
+ const timer = setTimeout(() => controller.abort(), timeout)
186
+
187
+ let response: Response
188
+ try {
189
+ response = await fetch(`${endpoint}/chat/completions`, {
190
+ method: 'POST',
191
+ headers: {
192
+ 'Content-Type': 'application/json',
193
+ ...headers,
194
+ },
195
+ body: JSON.stringify({
196
+ model: providerModel,
197
+ messages: options.messages,
198
+ temperature: options.temperature,
199
+ max_tokens: options.maxTokens,
200
+ }),
201
+ signal: controller.signal,
202
+ })
203
+ } catch (err) {
204
+ if (err instanceof Error && err.name === 'AbortError') {
205
+ throw new TimeoutError(
206
+ `Inference request timed out after ${timeout / 1000}s.`
207
+ )
208
+ }
209
+ throw err
210
+ } finally {
211
+ clearTimeout(timer)
212
+ }
213
+
214
+ if (!response.ok) {
215
+ const body = await response.text().catch(() => '')
216
+ throw new InferenceError(
217
+ `Provider returned ${response.status}: ${body}`,
218
+ response.status === 429
219
+ ? 'Provider is rate-limited. Wait a moment and retry, or try a different provider.'
220
+ : response.status >= 500
221
+ ? 'The provider is experiencing issues. Try a different provider with the provider option.'
222
+ : 'Check the model name and request parameters.'
223
+ )
224
+ }
225
+
226
+ const data = (await response.json()) as ChatCompletionResponse
227
+ const chatID = response.headers.get('ZG-Res-Key') ?? data.id ?? undefined
228
+
229
+ return { ...data, _chatID: chatID }
230
+ }
231
+
232
+ private async findProvider(model: string): Promise<string> {
233
+ const services = await this.listServices()
234
+ const match = services.find(
235
+ (s) => s.model.toLowerCase() === model.toLowerCase()
236
+ )
237
+ if (!match) {
238
+ const available = [...new Set(services.map((s) => s.model))].join(', ') || 'none'
239
+ throw new ProviderNotFoundError(
240
+ `No provider found for model "${model}". Available models: ${available}`
241
+ )
242
+ }
243
+ return match.provider
244
+ }
245
+
246
+ private async ensureLedgerFunded(
247
+ broker: ZGComputeNetworkBroker
248
+ ): Promise<void> {
249
+ try {
250
+ await broker.ledger.getLedger()
251
+ } catch {
252
+ // Ledger doesn't exist yet — create it with initial deposit
253
+ await broker.ledger.addLedger(DEFAULT_LEDGER_DEPOSIT)
254
+ }
255
+ }
256
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getNetwork, NETWORKS } from './networks.js'
3
+
4
+ describe('NETWORKS', () => {
5
+ it('testnet has correct chain ID', () => {
6
+ expect(NETWORKS.testnet.chainId).toBe(16602n)
7
+ })
8
+
9
+ it('mainnet has correct chain ID', () => {
10
+ expect(NETWORKS.mainnet.chainId).toBe(16661n)
11
+ })
12
+
13
+ it('testnet has multiple fallback RPC URLs', () => {
14
+ expect(NETWORKS.testnet.rpcUrls.length).toBeGreaterThanOrEqual(2)
15
+ expect(NETWORKS.testnet.rpcUrls[0]).toBe(NETWORKS.testnet.rpcUrl)
16
+ })
17
+
18
+ it('mainnet has multiple fallback RPC URLs', () => {
19
+ expect(NETWORKS.mainnet.rpcUrls.length).toBeGreaterThanOrEqual(2)
20
+ expect(NETWORKS.mainnet.rpcUrls[0]).toBe(NETWORKS.mainnet.rpcUrl)
21
+ })
22
+
23
+ it('all contract addresses are non-empty hex strings', () => {
24
+ for (const net of Object.values(NETWORKS)) {
25
+ expect(net.flowContractAddress).toMatch(/^0x[0-9a-fA-F]+$/)
26
+ expect(net.ledgerContractAddress).toMatch(/^0x[0-9a-fA-F]+$/)
27
+ expect(net.inferenceContractAddress).toMatch(/^0x[0-9a-fA-F]+$/)
28
+ expect(net.fineTuningContractAddress).toMatch(/^0x[0-9a-fA-F]+$/)
29
+ }
30
+ })
31
+ })
32
+
33
+ describe('getNetwork', () => {
34
+ it('resolves by name', () => {
35
+ const net = getNetwork('testnet')
36
+ expect(net.name).toBe('testnet')
37
+ expect(net.chainId).toBe(16602n)
38
+ })
39
+
40
+ it('resolves by chain ID', () => {
41
+ const net = getNetwork(16661n)
42
+ expect(net.name).toBe('mainnet')
43
+ })
44
+
45
+ it('throws on unknown name', () => {
46
+ expect(() => getNetwork('devnet' as any)).toThrow('Unknown network')
47
+ })
48
+
49
+ it('throws on unknown chain ID', () => {
50
+ expect(() => getNetwork(99999n)).toThrow('Unknown chain ID')
51
+ })
52
+
53
+ it('returns a copy — mutations do not affect originals', () => {
54
+ const net = getNetwork('testnet')
55
+ net.rpcUrl = 'https://mutated.example.com'
56
+ net.rpcUrls.push('https://extra.example.com')
57
+
58
+ const fresh = getNetwork('testnet')
59
+ expect(fresh.rpcUrl).not.toBe('https://mutated.example.com')
60
+ expect(fresh.rpcUrls).not.toContain('https://extra.example.com')
61
+ })
62
+ })
@@ -0,0 +1,62 @@
1
+ export type NetworkName = 'testnet' | 'mainnet'
2
+
3
+ export interface NetworkConfig {
4
+ name: NetworkName
5
+ chainId: bigint
6
+ rpcUrl: string
7
+ /** Fallback RPC URLs, tried in order if the primary fails */
8
+ rpcUrls: string[]
9
+ indexerUrl: string
10
+ flowContractAddress: string
11
+ ledgerContractAddress: string
12
+ inferenceContractAddress: string
13
+ fineTuningContractAddress: string
14
+ }
15
+
16
+ export const NETWORKS: Record<NetworkName, NetworkConfig> = {
17
+ testnet: {
18
+ name: 'testnet',
19
+ chainId: 16602n,
20
+ rpcUrl: 'https://evmrpc-testnet.0g.ai',
21
+ rpcUrls: [
22
+ 'https://evmrpc-testnet.0g.ai',
23
+ 'https://16600-rpc.testnet.0g.ai',
24
+ 'https://og-testnet-evm-rpc.allthatnode.com',
25
+ ],
26
+ indexerUrl: 'https://indexer-storage-testnet-turbo.0g.ai',
27
+ flowContractAddress: '0xbD2C3F0E65eDF5582141C35969d66e34e4E6BF80',
28
+ ledgerContractAddress: '0xE70830508dAc0A97e6c087c75f402f9Be669E406',
29
+ inferenceContractAddress: '0xa79F4c8311FF93C06b8CfB403690cc987c93F91E',
30
+ fineTuningContractAddress: '0xC6C075D8039763C8f1EbE580be5ADdf2fd6941bA',
31
+ },
32
+ mainnet: {
33
+ name: 'mainnet',
34
+ chainId: 16661n,
35
+ rpcUrl: 'https://evmrpc.0g.ai',
36
+ rpcUrls: [
37
+ 'https://evmrpc.0g.ai',
38
+ 'https://0g-rpc-evm01.validatorvn.com',
39
+ 'https://rpc.0g.thirdweb.com',
40
+ ],
41
+ indexerUrl: 'https://indexer-storage.0g.ai',
42
+ flowContractAddress: '0x0460aA47b41a66694c0a73f667a1b795A5ED3556',
43
+ ledgerContractAddress: '0x2dE54c845Cd948B72D2e32e39586fe89607074E3',
44
+ inferenceContractAddress: '0x47340d900bdFec2BD393c626E12ea0656F938d84',
45
+ fineTuningContractAddress: '0x4e3474095518883744ddf135b7E0A23301c7F9c0',
46
+ },
47
+ }
48
+
49
+ export function getNetwork(nameOrChainId: NetworkName | bigint): NetworkConfig {
50
+ if (typeof nameOrChainId === 'string') {
51
+ const config = NETWORKS[nameOrChainId]
52
+ if (!config) throw new Error(`Unknown network: ${nameOrChainId}`)
53
+ // Return a copy so overrides don't mutate the original
54
+ return { ...config, rpcUrls: [...config.rpcUrls] }
55
+ }
56
+ for (const config of Object.values(NETWORKS)) {
57
+ if (config.chainId === nameOrChainId) {
58
+ return { ...config, rpcUrls: [...config.rpcUrls] }
59
+ }
60
+ }
61
+ throw new Error(`Unknown chain ID: ${nameOrChainId}`)
62
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { ConnectionError } from './errors.js'
3
+
4
+ // Mock ethers before importing Orbit
5
+ vi.mock('ethers', () => {
6
+ const mockProvider = {
7
+ getNetwork: vi.fn().mockResolvedValue({ chainId: 16602n }),
8
+ getBalance: vi.fn().mockResolvedValue(1000000000000000000n), // 1 OG
9
+ }
10
+ const MockJsonRpcProvider = vi.fn(() => mockProvider)
11
+ const MockWallet = vi.fn((key: string, provider: any) => ({
12
+ address: '0x1234567890abcdef1234567890abcdef12345678',
13
+ provider,
14
+ }))
15
+ return {
16
+ JsonRpcProvider: MockJsonRpcProvider,
17
+ Wallet: MockWallet,
18
+ }
19
+ })
20
+
21
+ // Mock the storage and inference clients
22
+ vi.mock('./storage.js', () => ({
23
+ StorageClient: vi.fn(),
24
+ }))
25
+ vi.mock('./inference.js', () => ({
26
+ InferenceClient: vi.fn(),
27
+ }))
28
+
29
+ import { Orbit } from './orbit.js'
30
+ import { JsonRpcProvider } from 'ethers'
31
+
32
+ describe('Orbit.connect', () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks()
35
+ delete process.env.PRIVATE_KEY
36
+ })
37
+
38
+ it('connects with explicit private key', async () => {
39
+ const orbit = await Orbit.connect({
40
+ network: 'testnet',
41
+ privateKey: '0xabc123',
42
+ })
43
+ expect(orbit.address).toBe('0x1234567890abcdef1234567890abcdef12345678')
44
+ expect(orbit.network.name).toBe('testnet')
45
+ })
46
+
47
+ it('reads PRIVATE_KEY from env var', async () => {
48
+ process.env.PRIVATE_KEY = '0xenv_key_123'
49
+ const orbit = await Orbit.connect({ network: 'testnet' })
50
+ expect(orbit.address).toBeDefined()
51
+ })
52
+
53
+ it('throws with suggestion when no private key available', async () => {
54
+ await expect(
55
+ Orbit.connect({ network: 'testnet' })
56
+ ).rejects.toThrow('No private key provided')
57
+
58
+ try {
59
+ await Orbit.connect({ network: 'testnet' })
60
+ } catch (err) {
61
+ expect(err).toBeInstanceOf(ConnectionError)
62
+ expect((err as ConnectionError).suggestion).toContain('PRIVATE_KEY')
63
+ }
64
+ })
65
+
66
+ it('throws on chain ID mismatch', async () => {
67
+ const { JsonRpcProvider } = await import('ethers')
68
+ const mockProvider = new JsonRpcProvider() as any
69
+ mockProvider.getNetwork.mockResolvedValueOnce({ chainId: 99999n })
70
+
71
+ await expect(
72
+ Orbit.connect({ network: 'testnet', privateKey: '0xabc' })
73
+ ).rejects.toThrow('Chain ID mismatch')
74
+ })
75
+
76
+ it('warns on zero balance but still connects', async () => {
77
+ const { JsonRpcProvider } = await import('ethers')
78
+ const mockProvider = new JsonRpcProvider() as any
79
+ mockProvider.getNetwork.mockResolvedValueOnce({ chainId: 16602n })
80
+ mockProvider.getBalance.mockResolvedValueOnce(0n)
81
+
82
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
83
+
84
+ const orbit = await Orbit.connect({
85
+ network: 'testnet',
86
+ privateKey: '0xabc',
87
+ })
88
+
89
+ expect(orbit).toBeDefined()
90
+ expect(warnSpy).toHaveBeenCalledWith(
91
+ expect.stringContaining('0 OG balance')
92
+ )
93
+ warnSpy.mockRestore()
94
+ })
95
+
96
+ it('tries fallback RPC URLs on connection failure', async () => {
97
+ const { JsonRpcProvider } = await import('ethers')
98
+
99
+ // First two URLs fail, third succeeds
100
+ let callCount = 0
101
+ ;(JsonRpcProvider as any).mockImplementation(() => {
102
+ callCount++
103
+ const provider = {
104
+ getNetwork: callCount < 3
105
+ ? vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))
106
+ : vi.fn().mockResolvedValue({ chainId: 16602n }),
107
+ getBalance: vi.fn().mockResolvedValue(1000000000000000000n),
108
+ }
109
+ return provider
110
+ })
111
+
112
+ const orbit = await Orbit.connect({
113
+ network: 'testnet',
114
+ privateKey: '0xabc',
115
+ })
116
+ expect(orbit).toBeDefined()
117
+ expect(callCount).toBe(3)
118
+ })
119
+
120
+ it('throws when all RPC URLs fail', async () => {
121
+ const { JsonRpcProvider } = await import('ethers')
122
+ ;(JsonRpcProvider as any).mockImplementation(() => ({
123
+ getNetwork: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')),
124
+ }))
125
+
126
+ await expect(
127
+ Orbit.connect({ network: 'testnet', privateKey: '0xabc' })
128
+ ).rejects.toThrow('Failed to connect to any RPC endpoint')
129
+ })
130
+ })
131
+
132
+ describe('Orbit.status', () => {
133
+ beforeEach(() => {
134
+ vi.clearAllMocks()
135
+ // Restore default mock after tests that override JsonRpcProvider
136
+ const mockProvider = {
137
+ getNetwork: vi.fn().mockResolvedValue({ chainId: 16602n }),
138
+ getBalance: vi.fn().mockResolvedValue(1000000000000000000n),
139
+ }
140
+ ;(JsonRpcProvider as any).mockImplementation(() => mockProvider)
141
+ })
142
+
143
+ it('returns formatted balance and address', async () => {
144
+ const orbit = await Orbit.connect({
145
+ network: 'testnet',
146
+ privateKey: '0xabc',
147
+ })
148
+ const status = await orbit.status()
149
+ expect(status.network).toBe('testnet')
150
+ expect(status.address).toBe('0x1234567890abcdef1234567890abcdef12345678')
151
+ expect(status.balance).toMatch(/^\d+\.\d{6}$/)
152
+ })
153
+ })
@@ -0,0 +1,159 @@
1
+ import { Wallet, JsonRpcProvider } from 'ethers'
2
+ import type { NetworkName, NetworkConfig } from './networks.js'
3
+ import { getNetwork, NETWORKS } from './networks.js'
4
+ import { StorageClient } from './storage.js'
5
+ import { InferenceClient } from './inference.js'
6
+ import type {
7
+ OrbitConfig,
8
+ StoreResult,
9
+ StoreOptions,
10
+ RetrieveOptions,
11
+ InferResult,
12
+ InferOptions,
13
+ ServiceInfo,
14
+ AccountStatus,
15
+ } from './types.js'
16
+ import { ConnectionError } from './errors.js'
17
+
18
+ export class Orbit {
19
+ readonly network: NetworkConfig
20
+ readonly storage: StorageClient
21
+ readonly inference: InferenceClient
22
+ private wallet: Wallet
23
+ private provider: JsonRpcProvider
24
+
25
+ private constructor(
26
+ network: NetworkConfig,
27
+ wallet: Wallet,
28
+ provider: JsonRpcProvider
29
+ ) {
30
+ this.network = network
31
+ this.wallet = wallet
32
+ this.provider = provider
33
+ this.storage = new StorageClient(network, wallet)
34
+ this.inference = new InferenceClient(network, wallet)
35
+ }
36
+
37
+ /**
38
+ * Connect to the 0G network. This is the primary entry point.
39
+ *
40
+ * Tries the primary RPC URL first, then falls back to alternates.
41
+ * If privateKey is not provided, reads from PRIVATE_KEY env var.
42
+ */
43
+ static async connect(config: OrbitConfig): Promise<Orbit> {
44
+ // Resolve private key: explicit > env var
45
+ const privateKey = config.privateKey ?? process.env.PRIVATE_KEY
46
+ if (!privateKey) {
47
+ throw new ConnectionError(
48
+ 'No private key provided.',
49
+ 'Pass privateKey in config or set the PRIVATE_KEY environment variable. Example: PRIVATE_KEY=0x... npx tsx index.ts'
50
+ )
51
+ }
52
+
53
+ const networkConfig = getNetwork(config.network)
54
+
55
+ // Allow overrides
56
+ if (config.rpcUrl) networkConfig.rpcUrl = config.rpcUrl
57
+ if (config.indexerUrl) networkConfig.indexerUrl = config.indexerUrl
58
+
59
+ // Build candidate RPC URLs: explicit override first, then the fallback list
60
+ const rpcUrls = config.rpcUrl
61
+ ? [config.rpcUrl]
62
+ : networkConfig.rpcUrls
63
+
64
+ // Try each RPC URL until one connects
65
+ let lastError: Error | null = null
66
+ for (const url of rpcUrls) {
67
+ try {
68
+ const provider = new JsonRpcProvider(url)
69
+ const wallet = new Wallet(privateKey, provider)
70
+
71
+ const chainId = (await provider.getNetwork()).chainId
72
+ if (chainId !== networkConfig.chainId) {
73
+ throw new ConnectionError(
74
+ `Chain ID mismatch: expected ${networkConfig.chainId}, got ${chainId}`,
75
+ `Your RPC endpoint is on a different network. Use the correct RPC URL for ${networkConfig.name}.`
76
+ )
77
+ }
78
+
79
+ // Lock in the working URL
80
+ networkConfig.rpcUrl = url
81
+
82
+ // Check balance and warn if zero (non-blocking)
83
+ try {
84
+ const balance = await provider.getBalance(wallet.address)
85
+ if (balance === 0n) {
86
+ console.warn(
87
+ `[0G Orbit] Warning: wallet ${wallet.address} has 0 OG balance on ${networkConfig.name}. ` +
88
+ `Get testnet tokens at https://faucet.0g.ai`
89
+ )
90
+ }
91
+ } catch {
92
+ // Balance check failure is non-fatal
93
+ }
94
+
95
+ return new Orbit(networkConfig, wallet, provider)
96
+ } catch (err) {
97
+ // Chain ID mismatch is not a transient failure — don't try other URLs
98
+ if (err instanceof ConnectionError && err.message.includes('Chain ID mismatch')) {
99
+ throw err
100
+ }
101
+ lastError = err instanceof Error ? err : new Error(String(err))
102
+ }
103
+ }
104
+
105
+ const tried = rpcUrls.join(', ')
106
+ throw new ConnectionError(
107
+ `Failed to connect to any RPC endpoint. Tried: ${tried}. Last error: ${lastError?.message ?? 'unknown'}`,
108
+ 'All RPC endpoints are unreachable. Check your internet connection, or provide a custom RPC URL with the rpcUrl option.'
109
+ )
110
+ }
111
+
112
+ // --- Storage shortcuts ---
113
+
114
+ async store(filePath: string, options?: StoreOptions): Promise<StoreResult> {
115
+ return this.storage.store(filePath, options)
116
+ }
117
+
118
+ async storeData(
119
+ data: string | Buffer | Uint8Array,
120
+ options?: StoreOptions
121
+ ): Promise<StoreResult> {
122
+ return this.storage.storeData(data, options)
123
+ }
124
+
125
+ async retrieve(
126
+ rootHash: string,
127
+ outputPath: string,
128
+ options?: RetrieveOptions
129
+ ): Promise<void> {
130
+ return this.storage.retrieve(rootHash, outputPath, options)
131
+ }
132
+
133
+ // --- Inference shortcuts ---
134
+
135
+ async infer(model: string, options: InferOptions): Promise<InferResult> {
136
+ return this.inference.infer(model, options)
137
+ }
138
+
139
+ async listServices(): Promise<ServiceInfo[]> {
140
+ return this.inference.listServices()
141
+ }
142
+
143
+ // --- Account ---
144
+
145
+ async status(): Promise<AccountStatus> {
146
+ const balance = await this.provider.getBalance(this.wallet.address)
147
+ const balanceOG = Number(balance) / 1e18
148
+
149
+ return {
150
+ balance: balanceOG.toFixed(6),
151
+ network: this.network.name,
152
+ address: this.wallet.address,
153
+ }
154
+ }
155
+
156
+ get address(): string {
157
+ return this.wallet.address
158
+ }
159
+ }