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,99 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { withRetry, isTransientError } from './retry.js'
|
|
3
|
+
|
|
4
|
+
describe('withRetry', () => {
|
|
5
|
+
it('returns on first success', async () => {
|
|
6
|
+
const fn = vi.fn().mockResolvedValue('ok')
|
|
7
|
+
const result = await withRetry(fn)
|
|
8
|
+
expect(result).toBe('ok')
|
|
9
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('retries on transient error and succeeds', async () => {
|
|
13
|
+
const fn = vi.fn()
|
|
14
|
+
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
|
|
15
|
+
.mockResolvedValue('ok')
|
|
16
|
+
|
|
17
|
+
const result = await withRetry(fn, { baseDelay: 10 })
|
|
18
|
+
expect(result).toBe('ok')
|
|
19
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('throws after maxAttempts exhausted', async () => {
|
|
23
|
+
const fn = vi.fn().mockRejectedValue(new Error('ETIMEDOUT'))
|
|
24
|
+
|
|
25
|
+
await expect(
|
|
26
|
+
withRetry(fn, { maxAttempts: 3, baseDelay: 10 })
|
|
27
|
+
).rejects.toThrow('ETIMEDOUT')
|
|
28
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('does not retry non-transient errors', async () => {
|
|
32
|
+
const fn = vi.fn().mockRejectedValue(new Error('Invalid private key'))
|
|
33
|
+
|
|
34
|
+
await expect(
|
|
35
|
+
withRetry(fn, { maxAttempts: 3, baseDelay: 10 })
|
|
36
|
+
).rejects.toThrow('Invalid private key')
|
|
37
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('calls onRetry callback', async () => {
|
|
41
|
+
const onRetry = vi.fn()
|
|
42
|
+
const fn = vi.fn()
|
|
43
|
+
.mockRejectedValueOnce(new Error('ECONNRESET'))
|
|
44
|
+
.mockResolvedValue('ok')
|
|
45
|
+
|
|
46
|
+
await withRetry(fn, { baseDelay: 10, onRetry })
|
|
47
|
+
expect(onRetry).toHaveBeenCalledTimes(1)
|
|
48
|
+
expect(onRetry).toHaveBeenCalledWith(1, expect.any(Error))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('respects custom isRetryable predicate', async () => {
|
|
52
|
+
const fn = vi.fn()
|
|
53
|
+
.mockRejectedValueOnce(new Error('custom-retryable'))
|
|
54
|
+
.mockResolvedValue('ok')
|
|
55
|
+
|
|
56
|
+
const result = await withRetry(fn, {
|
|
57
|
+
baseDelay: 10,
|
|
58
|
+
isRetryable: (err) => err instanceof Error && err.message === 'custom-retryable',
|
|
59
|
+
})
|
|
60
|
+
expect(result).toBe('ok')
|
|
61
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('isTransientError', () => {
|
|
66
|
+
it.each([
|
|
67
|
+
'ECONNREFUSED',
|
|
68
|
+
'ECONNRESET',
|
|
69
|
+
'ETIMEDOUT',
|
|
70
|
+
'ENOTFOUND',
|
|
71
|
+
'fetch failed',
|
|
72
|
+
'network error',
|
|
73
|
+
'timeout exceeded',
|
|
74
|
+
'server error',
|
|
75
|
+
'bad gateway',
|
|
76
|
+
'service unavailable',
|
|
77
|
+
'rate limit exceeded',
|
|
78
|
+
'missing response',
|
|
79
|
+
'connection error',
|
|
80
|
+
'could not detect network',
|
|
81
|
+
])('returns true for "%s"', (msg) => {
|
|
82
|
+
expect(isTransientError(new Error(msg))).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it.each([
|
|
86
|
+
'Invalid private key',
|
|
87
|
+
'insufficient funds for gas',
|
|
88
|
+
'File not found',
|
|
89
|
+
'unknown error',
|
|
90
|
+
])('returns false for "%s"', (msg) => {
|
|
91
|
+
expect(isTransientError(new Error(msg))).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns false for non-Error values', () => {
|
|
95
|
+
expect(isTransientError('string error')).toBe(false)
|
|
96
|
+
expect(isTransientError(null)).toBe(false)
|
|
97
|
+
expect(isTransientError(42)).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
/** Maximum number of attempts (default: 3) */
|
|
3
|
+
maxAttempts?: number
|
|
4
|
+
/** Base delay between retries in ms (default: 1000). Doubles each retry. */
|
|
5
|
+
baseDelay?: number
|
|
6
|
+
/** Maximum delay cap in ms (default: 10000) */
|
|
7
|
+
maxDelay?: number
|
|
8
|
+
/** Predicate to decide if an error is retryable (default: transient errors only) */
|
|
9
|
+
isRetryable?: (err: unknown) => boolean
|
|
10
|
+
/** Called before each retry with attempt number and error */
|
|
11
|
+
onRetry?: (attempt: number, err: unknown) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'onRetry'>> = {
|
|
15
|
+
maxAttempts: 3,
|
|
16
|
+
baseDelay: 1000,
|
|
17
|
+
maxDelay: 10_000,
|
|
18
|
+
isRetryable: isTransientError,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Retry an async operation with exponential backoff.
|
|
23
|
+
* Only retries on transient errors (network, timeout, 5xx) by default.
|
|
24
|
+
*/
|
|
25
|
+
export async function withRetry<T>(
|
|
26
|
+
fn: () => Promise<T>,
|
|
27
|
+
options: RetryOptions = {}
|
|
28
|
+
): Promise<T> {
|
|
29
|
+
const opts = { ...DEFAULT_OPTIONS, ...options }
|
|
30
|
+
let lastError: unknown
|
|
31
|
+
|
|
32
|
+
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
return await fn()
|
|
35
|
+
} catch (err) {
|
|
36
|
+
lastError = err
|
|
37
|
+
|
|
38
|
+
if (attempt === opts.maxAttempts || !opts.isRetryable(err)) {
|
|
39
|
+
throw err
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (options.onRetry) {
|
|
43
|
+
options.onRetry(attempt, err)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Exponential backoff with jitter
|
|
47
|
+
const delay = Math.min(
|
|
48
|
+
opts.baseDelay * Math.pow(2, attempt - 1) + Math.random() * 500,
|
|
49
|
+
opts.maxDelay
|
|
50
|
+
)
|
|
51
|
+
await sleep(delay)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw lastError
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sleep(ms: number): Promise<void> {
|
|
59
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Determines if an error is transient and worth retrying.
|
|
64
|
+
* Covers: network failures, timeouts, RPC errors, 5xx responses.
|
|
65
|
+
*/
|
|
66
|
+
export function isTransientError(err: unknown): boolean {
|
|
67
|
+
if (!(err instanceof Error)) return false
|
|
68
|
+
const msg = err.message.toLowerCase()
|
|
69
|
+
|
|
70
|
+
// Network-level failures
|
|
71
|
+
if (msg.includes('econnrefused')) return true
|
|
72
|
+
if (msg.includes('econnreset')) return true
|
|
73
|
+
if (msg.includes('etimedout')) return true
|
|
74
|
+
if (msg.includes('enotfound')) return true
|
|
75
|
+
if (msg.includes('epipe')) return true
|
|
76
|
+
if (msg.includes('fetch failed')) return true
|
|
77
|
+
if (msg.includes('network error')) return true
|
|
78
|
+
|
|
79
|
+
// Timeout
|
|
80
|
+
if (msg.includes('timeout')) return true
|
|
81
|
+
if (err.name === 'AbortError') return true
|
|
82
|
+
|
|
83
|
+
// RPC-level transient errors
|
|
84
|
+
if (msg.includes('server error')) return true
|
|
85
|
+
if (msg.includes('bad gateway')) return true
|
|
86
|
+
if (msg.includes('service unavailable')) return true
|
|
87
|
+
if (msg.includes('rate limit')) return true
|
|
88
|
+
if (msg.includes('429')) return true
|
|
89
|
+
if (msg.includes('502')) return true
|
|
90
|
+
if (msg.includes('503')) return true
|
|
91
|
+
if (msg.includes('504')) return true
|
|
92
|
+
|
|
93
|
+
// ethers provider errors
|
|
94
|
+
if (msg.includes('missing response')) return true
|
|
95
|
+
if (msg.includes('connection error')) return true
|
|
96
|
+
if (msg.includes('could not detect network')) return true
|
|
97
|
+
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
// Mock the 0G SDK
|
|
7
|
+
vi.mock('@0gfoundation/0g-ts-sdk', () => {
|
|
8
|
+
const mockClose = vi.fn()
|
|
9
|
+
const mockMerkleTree = vi.fn().mockResolvedValue([{ rootHash: '0xabc' }, null])
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
ZgFile: {
|
|
13
|
+
fromFilePath: vi.fn().mockResolvedValue({
|
|
14
|
+
merkleTree: mockMerkleTree,
|
|
15
|
+
close: mockClose,
|
|
16
|
+
size: () => 100,
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
Indexer: vi.fn().mockImplementation(() => ({
|
|
20
|
+
upload: vi.fn().mockResolvedValue([
|
|
21
|
+
{ txHash: '0xtx123', rootHash: '0xroot456' },
|
|
22
|
+
null,
|
|
23
|
+
]),
|
|
24
|
+
download: vi.fn().mockResolvedValue(null),
|
|
25
|
+
})),
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
import { StorageClient } from './storage.js'
|
|
30
|
+
import { StorageError } from './errors.js'
|
|
31
|
+
import { NETWORKS } from './networks.js'
|
|
32
|
+
import { Indexer, ZgFile } from '@0gfoundation/0g-ts-sdk'
|
|
33
|
+
|
|
34
|
+
const mockSigner = { address: '0x123' } as any
|
|
35
|
+
const testNetwork = { ...NETWORKS.testnet }
|
|
36
|
+
|
|
37
|
+
describe('StorageClient', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('store', () => {
|
|
43
|
+
it('uploads a file and returns root + tx hash', async () => {
|
|
44
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
45
|
+
const result = await client.store('/tmp/test.txt')
|
|
46
|
+
expect(result.root).toBe('0xroot456')
|
|
47
|
+
expect(result.txHash).toBe('0xtx123')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('passes tags and replicas to upload', async () => {
|
|
51
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
52
|
+
await client.store('/tmp/test.txt', { tags: '0xbeef', replicas: 3 })
|
|
53
|
+
|
|
54
|
+
const indexerInstance = (Indexer as any).mock.results[0].value
|
|
55
|
+
expect(indexerInstance.upload).toHaveBeenCalledWith(
|
|
56
|
+
expect.anything(),
|
|
57
|
+
testNetwork.rpcUrl,
|
|
58
|
+
expect.anything(),
|
|
59
|
+
expect.objectContaining({ tags: '0xbeef', expectedReplica: 3 }),
|
|
60
|
+
expect.objectContaining({ Retries: 5, Interval: 3 })
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('always passes gas retry options', async () => {
|
|
65
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
66
|
+
await client.store('/tmp/test.txt')
|
|
67
|
+
|
|
68
|
+
const indexerInstance = (Indexer as any).mock.results[0].value
|
|
69
|
+
const retryOpts = indexerInstance.upload.mock.calls[0][4]
|
|
70
|
+
expect(retryOpts).toBeDefined()
|
|
71
|
+
expect(retryOpts.Retries).toBe(5)
|
|
72
|
+
expect(retryOpts.MaxGasPrice).toBeGreaterThan(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('throws StorageError with suggestion on merkle failure', async () => {
|
|
76
|
+
const mockFile = {
|
|
77
|
+
merkleTree: vi.fn().mockResolvedValue([null, new Error('corrupt file')]),
|
|
78
|
+
close: vi.fn(),
|
|
79
|
+
}
|
|
80
|
+
;(ZgFile.fromFilePath as any).mockResolvedValueOnce(mockFile)
|
|
81
|
+
|
|
82
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
83
|
+
try {
|
|
84
|
+
await client.store('/tmp/bad.txt')
|
|
85
|
+
expect.unreachable('should have thrown')
|
|
86
|
+
} catch (err) {
|
|
87
|
+
expect(err).toBeInstanceOf(StorageError)
|
|
88
|
+
expect((err as StorageError).suggestion).toContain('file exists')
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('throws StorageError with gas suggestion on underpriced', async () => {
|
|
93
|
+
const indexerMock = {
|
|
94
|
+
upload: vi.fn().mockResolvedValue([
|
|
95
|
+
{ txHash: '', rootHash: '' },
|
|
96
|
+
new Error('transaction underpriced'),
|
|
97
|
+
]),
|
|
98
|
+
download: vi.fn(),
|
|
99
|
+
}
|
|
100
|
+
;(Indexer as any).mockImplementationOnce(() => indexerMock)
|
|
101
|
+
|
|
102
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
103
|
+
try {
|
|
104
|
+
await client.store('/tmp/test.txt')
|
|
105
|
+
expect.unreachable('should have thrown')
|
|
106
|
+
} catch (err) {
|
|
107
|
+
expect(err).toBeInstanceOf(StorageError)
|
|
108
|
+
expect((err as StorageError).suggestion).toContain('maxGasPrice')
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('always closes the file even on error', async () => {
|
|
113
|
+
const mockClose = vi.fn()
|
|
114
|
+
const mockFile = {
|
|
115
|
+
merkleTree: vi.fn().mockRejectedValue(new Error('boom')),
|
|
116
|
+
close: mockClose,
|
|
117
|
+
}
|
|
118
|
+
;(ZgFile.fromFilePath as any).mockResolvedValueOnce(mockFile)
|
|
119
|
+
|
|
120
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
121
|
+
await client.store('/tmp/test.txt').catch(() => {})
|
|
122
|
+
expect(mockClose).toHaveBeenCalled()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('storeData', () => {
|
|
127
|
+
it('stores a string by writing to temp file', async () => {
|
|
128
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
129
|
+
const result = await client.storeData('hello world')
|
|
130
|
+
expect(result.root).toBe('0xroot456')
|
|
131
|
+
expect(result.txHash).toBe('0xtx123')
|
|
132
|
+
|
|
133
|
+
// Verify ZgFile.fromFilePath was called with a temp path
|
|
134
|
+
const calledPath = (ZgFile.fromFilePath as any).mock.calls[0][0]
|
|
135
|
+
expect(calledPath).toContain('orbit-')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('stores a Buffer', async () => {
|
|
139
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
140
|
+
const result = await client.storeData(Buffer.from('binary data'))
|
|
141
|
+
expect(result.root).toBe('0xroot456')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('stores a Uint8Array', async () => {
|
|
145
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
146
|
+
const result = await client.storeData(new Uint8Array([1, 2, 3]))
|
|
147
|
+
expect(result.root).toBe('0xroot456')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('cleans up temp file after upload', async () => {
|
|
151
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
152
|
+
await client.storeData('cleanup test')
|
|
153
|
+
|
|
154
|
+
const calledPath = (ZgFile.fromFilePath as any).mock.calls[0][0]
|
|
155
|
+
// Temp file should have been deleted
|
|
156
|
+
expect(existsSync(calledPath)).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('cleans up temp file even on error', async () => {
|
|
160
|
+
const mockFile = {
|
|
161
|
+
merkleTree: vi.fn().mockResolvedValue([null, new Error('fail')]),
|
|
162
|
+
close: vi.fn(),
|
|
163
|
+
}
|
|
164
|
+
;(ZgFile.fromFilePath as any).mockResolvedValueOnce(mockFile)
|
|
165
|
+
|
|
166
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
167
|
+
await client.storeData('error cleanup test').catch(() => {})
|
|
168
|
+
|
|
169
|
+
const calledPath = (ZgFile.fromFilePath as any).mock.calls[0][0]
|
|
170
|
+
expect(existsSync(calledPath)).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('retrieve', () => {
|
|
175
|
+
it('downloads successfully', async () => {
|
|
176
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
177
|
+
await expect(
|
|
178
|
+
client.retrieve('0xroot', '/tmp/output.txt')
|
|
179
|
+
).resolves.toBeUndefined()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('throws StorageError on download failure', async () => {
|
|
183
|
+
const indexerMock = {
|
|
184
|
+
upload: vi.fn(),
|
|
185
|
+
download: vi.fn().mockResolvedValue(new Error('file not found: 404')),
|
|
186
|
+
}
|
|
187
|
+
;(Indexer as any).mockImplementationOnce(() => indexerMock)
|
|
188
|
+
|
|
189
|
+
const client = new StorageClient(testNetwork, mockSigner)
|
|
190
|
+
try {
|
|
191
|
+
await client.retrieve('0xbad', '/tmp/out.txt')
|
|
192
|
+
expect.unreachable('should have thrown')
|
|
193
|
+
} catch (err) {
|
|
194
|
+
expect(err).toBeInstanceOf(StorageError)
|
|
195
|
+
expect((err as StorageError).suggestion).toContain('root hash')
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
})
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Indexer, ZgFile } from '@0gfoundation/0g-ts-sdk'
|
|
2
|
+
import type { Signer } from 'ethers'
|
|
3
|
+
import { writeFileSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import type { NetworkConfig } from './networks.js'
|
|
8
|
+
import type { StoreResult, StoreOptions, RetrieveOptions } from './types.js'
|
|
9
|
+
import { StorageError } from './errors.js'
|
|
10
|
+
import { withRetry, isTransientError } from './retry.js'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_REPLICAS = 1
|
|
13
|
+
const DEFAULT_MAX_GAS_PRICE = 25_000_000_000 // 25 gwei — generous ceiling for auto-escalation
|
|
14
|
+
|
|
15
|
+
export class StorageClient {
|
|
16
|
+
private indexer: Indexer
|
|
17
|
+
private rpcUrl: string
|
|
18
|
+
private signer: Signer
|
|
19
|
+
|
|
20
|
+
constructor(network: NetworkConfig, signer: Signer) {
|
|
21
|
+
this.indexer = new Indexer(network.indexerUrl)
|
|
22
|
+
this.rpcUrl = network.rpcUrl
|
|
23
|
+
this.signer = signer
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Upload a file to 0G Storage by file path.
|
|
28
|
+
* Retries transient failures automatically. Gas escalates on each retry.
|
|
29
|
+
*/
|
|
30
|
+
async store(filePath: string, options: StoreOptions = {}): Promise<StoreResult> {
|
|
31
|
+
return withRetry(
|
|
32
|
+
() => this._store(filePath, options),
|
|
33
|
+
{
|
|
34
|
+
maxAttempts: 3,
|
|
35
|
+
isRetryable: (err) => {
|
|
36
|
+
if (!(err instanceof StorageError)) return isTransientError(err)
|
|
37
|
+
const msg = err.message.toLowerCase()
|
|
38
|
+
return msg.includes('gas') ||
|
|
39
|
+
msg.includes('timeout') ||
|
|
40
|
+
msg.includes('etimedout') ||
|
|
41
|
+
msg.includes('econnrefused') ||
|
|
42
|
+
msg.includes('underpriced')
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Upload raw data (string, Buffer, or Uint8Array) to 0G Storage.
|
|
50
|
+
* Writes to a temp file, uploads, then cleans up.
|
|
51
|
+
*/
|
|
52
|
+
async storeData(
|
|
53
|
+
data: string | Buffer | Uint8Array,
|
|
54
|
+
options: StoreOptions = {}
|
|
55
|
+
): Promise<StoreResult> {
|
|
56
|
+
const tempPath = join(tmpdir(), `orbit-${randomBytes(8).toString('hex')}`)
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(tempPath, data)
|
|
59
|
+
return await this.store(tempPath, options)
|
|
60
|
+
} finally {
|
|
61
|
+
try { unlinkSync(tempPath) } catch { /* ignore cleanup errors */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async _store(filePath: string, options: StoreOptions): Promise<StoreResult> {
|
|
66
|
+
const file = await ZgFile.fromFilePath(filePath)
|
|
67
|
+
try {
|
|
68
|
+
const [tree, treeErr] = await file.merkleTree()
|
|
69
|
+
if (treeErr || !tree) {
|
|
70
|
+
throw new StorageError(
|
|
71
|
+
`Failed to compute merkle tree: ${treeErr?.message ?? 'unknown error'}`,
|
|
72
|
+
'Ensure the file exists, is readable, and is not empty.'
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Always enable gas auto-escalation via the SDK's built-in retry.
|
|
77
|
+
// MaxGasPrice sets the ceiling — the SDK starts from the network estimate
|
|
78
|
+
// and bumps 10% on each retry until it hits this cap.
|
|
79
|
+
const maxGas = options.maxGasPrice
|
|
80
|
+
? Number(options.maxGasPrice)
|
|
81
|
+
: DEFAULT_MAX_GAS_PRICE
|
|
82
|
+
|
|
83
|
+
// Cast signer to avoid ESM/CJS ethers type mismatch
|
|
84
|
+
const [result, uploadErr] = await this.indexer.upload(
|
|
85
|
+
file,
|
|
86
|
+
this.rpcUrl,
|
|
87
|
+
this.signer as any,
|
|
88
|
+
{
|
|
89
|
+
tags: options.tags ?? '0x',
|
|
90
|
+
expectedReplica: options.replicas ?? DEFAULT_REPLICAS,
|
|
91
|
+
},
|
|
92
|
+
{ Retries: 5, Interval: 3, MaxGasPrice: maxGas }
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (uploadErr) {
|
|
96
|
+
const msg = uploadErr.message
|
|
97
|
+
let suggestion = 'Check your OG balance and try again.'
|
|
98
|
+
if (msg.includes('gas') || msg.includes('underpriced')) {
|
|
99
|
+
suggestion = `Gas auto-escalation hit the ceiling (${maxGas} wei). Try a higher maxGasPrice, e.g. { maxGasPrice: ${maxGas * 2}n }`
|
|
100
|
+
} else if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
|
|
101
|
+
suggestion = 'The RPC endpoint timed out. Try a different RPC URL with the rpcUrl option.'
|
|
102
|
+
} else if (msg.includes('insufficient funds')) {
|
|
103
|
+
suggestion = 'Your wallet needs more OG for gas. Get testnet OG from: https://faucet.0g.ai'
|
|
104
|
+
}
|
|
105
|
+
throw new StorageError(`Upload failed: ${msg}`, suggestion)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if ('txHash' in result) {
|
|
109
|
+
return {
|
|
110
|
+
root: result.rootHash,
|
|
111
|
+
txHash: result.txHash,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fragmented upload — return the first root hash as primary handle
|
|
116
|
+
return {
|
|
117
|
+
root: result.rootHashes[0],
|
|
118
|
+
txHash: result.txHashes[0],
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
await file.close()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Download a file from 0G Storage by root hash.
|
|
127
|
+
*/
|
|
128
|
+
async retrieve(
|
|
129
|
+
rootHash: string,
|
|
130
|
+
outputPath: string,
|
|
131
|
+
options: RetrieveOptions = {}
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
return withRetry(
|
|
134
|
+
() => this._retrieve(rootHash, outputPath, options),
|
|
135
|
+
{ maxAttempts: 3 }
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async _retrieve(
|
|
140
|
+
rootHash: string,
|
|
141
|
+
outputPath: string,
|
|
142
|
+
options: RetrieveOptions
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const err = await this.indexer.download(
|
|
145
|
+
rootHash,
|
|
146
|
+
outputPath,
|
|
147
|
+
options.proof ?? false
|
|
148
|
+
)
|
|
149
|
+
if (err) {
|
|
150
|
+
const msg = err.message
|
|
151
|
+
let suggestion = 'Verify the root hash is correct and the file was uploaded successfully.'
|
|
152
|
+
if (msg.includes('not found') || msg.includes('404')) {
|
|
153
|
+
suggestion = 'This root hash was not found on the network. Double-check the hash or ensure the upload completed.'
|
|
154
|
+
}
|
|
155
|
+
throw new StorageError(`Download failed: ${msg}`, suggestion)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { NetworkName } from './networks.js'
|
|
2
|
+
|
|
3
|
+
export interface OrbitConfig {
|
|
4
|
+
/** Private key for signing transactions. Falls back to PRIVATE_KEY env var if omitted. */
|
|
5
|
+
privateKey?: string
|
|
6
|
+
/** Network to connect to */
|
|
7
|
+
network: NetworkName
|
|
8
|
+
/** Custom RPC URL (overrides network default) */
|
|
9
|
+
rpcUrl?: string
|
|
10
|
+
/** Custom indexer URL (overrides network default) */
|
|
11
|
+
indexerUrl?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StoreResult {
|
|
15
|
+
/** Merkle root hash — use this to retrieve the file */
|
|
16
|
+
root: string
|
|
17
|
+
/** On-chain transaction hash */
|
|
18
|
+
txHash: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StoreOptions {
|
|
22
|
+
/** Custom tags for the upload (hex string) */
|
|
23
|
+
tags?: string
|
|
24
|
+
/** Expected number of replicas */
|
|
25
|
+
replicas?: number
|
|
26
|
+
/** Maximum gas price in wei */
|
|
27
|
+
maxGasPrice?: bigint
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RetrieveOptions {
|
|
31
|
+
/** Whether to verify merkle proofs during download */
|
|
32
|
+
proof?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface InferResult {
|
|
36
|
+
/** The model's response text */
|
|
37
|
+
content: string
|
|
38
|
+
/** The model used */
|
|
39
|
+
model: string
|
|
40
|
+
/** Token usage */
|
|
41
|
+
usage?: {
|
|
42
|
+
promptTokens: number
|
|
43
|
+
completionTokens: number
|
|
44
|
+
totalTokens: number
|
|
45
|
+
}
|
|
46
|
+
/** Whether the response passed TEE verification (null if not verifiable) */
|
|
47
|
+
verified: boolean | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InferOptions {
|
|
51
|
+
/** Messages for chat completion */
|
|
52
|
+
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
|
|
53
|
+
/** Temperature for sampling (0-2) */
|
|
54
|
+
temperature?: number
|
|
55
|
+
/** Maximum tokens to generate */
|
|
56
|
+
maxTokens?: number
|
|
57
|
+
/** Specific provider address (auto-selected if omitted) */
|
|
58
|
+
provider?: string
|
|
59
|
+
/** Request timeout in milliseconds (default: 30000) */
|
|
60
|
+
timeout?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ServiceInfo {
|
|
64
|
+
/** Provider wallet address */
|
|
65
|
+
provider: string
|
|
66
|
+
/** Model name */
|
|
67
|
+
model: string
|
|
68
|
+
/** Service endpoint URL */
|
|
69
|
+
url: string
|
|
70
|
+
/** Input token price (neuron) */
|
|
71
|
+
inputPrice: bigint
|
|
72
|
+
/** Output token price (neuron) */
|
|
73
|
+
outputPrice: bigint
|
|
74
|
+
/** Whether TEE verification is available */
|
|
75
|
+
verifiable: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AccountStatus {
|
|
79
|
+
/** Main ledger balance in OG */
|
|
80
|
+
balance: string
|
|
81
|
+
/** Network name */
|
|
82
|
+
network: NetworkName
|
|
83
|
+
/** Wallet address */
|
|
84
|
+
address: string
|
|
85
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"outDir": "./dist",
|
|
16
|
+
"rootDir": "./src"
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*.ts"],
|
|
19
|
+
"exclude": ["src/**/*.test.ts"]
|
|
20
|
+
}
|