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.
- package/dist/cli/cli.d.ts +3 -0
- package/dist/cli/cli.d.ts.map +1 -0
- package/dist/cli/cli.js +59 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/cli/commands/account.d.ts +6 -0
- package/dist/cli/commands/account.d.ts.map +1 -0
- package/dist/cli/commands/account.js +23 -0
- package/dist/cli/commands/account.js.map +1 -0
- package/dist/cli/commands/inference.d.ts +15 -0
- package/dist/cli/commands/inference.d.ts.map +1 -0
- package/dist/cli/commands/inference.js +70 -0
- package/dist/cli/commands/inference.js.map +1 -0
- package/dist/cli/commands/init.d.ts +7 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +60 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/storage.d.ts +19 -0
- package/dist/cli/commands/storage.d.ts.map +1 -0
- package/dist/cli/commands/storage.js +62 -0
- package/dist/cli/commands/storage.js.map +1 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.d.ts.map +1 -0
- package/dist/cli/utils.js +20 -0
- package/dist/cli/utils.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +51 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/inference.d.ts +17 -0
- package/dist/inference.d.ts.map +1 -0
- package/dist/inference.js +179 -0
- package/dist/inference.js.map +1 -0
- package/dist/networks.d.ts +16 -0
- package/dist/networks.d.ts.map +1 -0
- package/dist/networks.js +48 -0
- package/dist/networks.js.map +1 -0
- package/dist/orbit.d.ts +27 -0
- package/dist/orbit.d.ts.map +1 -0
- package/dist/orbit.js +108 -0
- package/dist/orbit.js.map +1 -0
- package/dist/retry.d.ts +23 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +90 -0
- package/dist/retry.js.map +1 -0
- package/dist/storage.d.ts +26 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +121 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/examples/ai-chatbot/index.ts +74 -0
- package/examples/model-registry/index.ts +137 -0
- package/examples/quick-start/index.ts +65 -0
- package/package.json +42 -0
- package/packages/cli/package.json +30 -0
- package/packages/cli/src/cli.ts +69 -0
- package/packages/cli/src/commands/account.ts +29 -0
- package/packages/cli/src/commands/inference.ts +103 -0
- package/packages/cli/src/commands/init.ts +71 -0
- package/packages/cli/src/commands/storage.ts +91 -0
- package/packages/cli/src/utils.ts +21 -0
- package/packages/cli/tsconfig.json +8 -0
- package/packages/core/package.json +35 -0
- package/packages/core/src/errors.test.ts +99 -0
- package/packages/core/src/errors.ts +79 -0
- package/packages/core/src/index.ts +37 -0
- package/packages/core/src/inference.ts +256 -0
- package/packages/core/src/networks.test.ts +62 -0
- package/packages/core/src/networks.ts +62 -0
- package/packages/core/src/orbit.test.ts +153 -0
- package/packages/core/src/orbit.ts +159 -0
- package/packages/core/src/retry.test.ts +99 -0
- package/packages/core/src/retry.ts +99 -0
- package/packages/core/src/storage.test.ts +199 -0
- package/packages/core/src/storage.ts +158 -0
- package/packages/core/src/types.ts +85 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/core/vitest.config.ts +7 -0
- package/src/cli/cli.ts +69 -0
- package/src/cli/commands/account.ts +29 -0
- package/src/cli/commands/inference.ts +103 -0
- package/src/cli/commands/init.ts +71 -0
- package/src/cli/commands/storage.ts +91 -0
- package/src/cli/utils.ts +21 -0
- package/src/errors.test.ts +99 -0
- package/src/errors.ts +79 -0
- package/src/index.ts +37 -0
- package/src/inference.ts +256 -0
- package/src/networks.test.ts +62 -0
- package/src/networks.ts +62 -0
- package/src/orbit.test.ts +153 -0
- package/src/orbit.ts +159 -0
- package/src/retry.test.ts +99 -0
- package/src/retry.ts +99 -0
- package/src/storage.test.ts +199 -0
- package/src/storage.ts +158 -0
- package/src/types.ts +85 -0
- package/tsconfig.json +20 -0
- 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
|
+
}
|