@1sat/cli 0.0.63 → 0.0.64
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/package.json +8 -7
- package/src/commands/serve.ts +68 -8
- package/src/config.ts +17 -0
- package/src/repricer/buildPriceUpdateTask.ts +80 -0
- package/src/repricer/computeReprice.ts +34 -0
- package/src/repricer/configLoader.ts +37 -0
- package/src/repricer/index.ts +14 -0
- package/src/repricer/providers.ts +20 -0
- package/src/repricer/types.ts +28 -0
- package/src/repricer/whatsOnChain.ts +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1sat/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.64",
|
|
4
4
|
"description": "CLI for 1Sat Ordinals SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/cli.ts",
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"preinstall": "node scripts/check-bun.cjs",
|
|
16
16
|
"build": "bun build ./src/cli.ts --compile --outfile=bin/1sat --external pg --external pg-native --external pg-query-stream --external tedious --external oracledb --external mysql",
|
|
17
17
|
"dev": "bun run src/cli.ts",
|
|
18
|
-
"lint": "biome check src"
|
|
18
|
+
"lint": "biome check src",
|
|
19
|
+
"test": "bun test"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|
|
21
22
|
"1sat",
|
|
@@ -25,11 +26,11 @@
|
|
|
25
26
|
],
|
|
26
27
|
"license": "MIT",
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"@1sat/actions": "0.0.
|
|
29
|
-
"@1sat/client": "0.0.
|
|
30
|
-
"@1sat/types": "0.0.
|
|
31
|
-
"@1sat/wallet-node": "0.0.
|
|
32
|
-
"@1sat/wallet-server": "0.0.
|
|
29
|
+
"@1sat/actions": "0.0.162",
|
|
30
|
+
"@1sat/client": "0.0.38",
|
|
31
|
+
"@1sat/types": "0.0.30",
|
|
32
|
+
"@1sat/wallet-node": "0.0.51",
|
|
33
|
+
"@1sat/wallet-server": "0.0.20",
|
|
33
34
|
"@bsv/sdk": "^2.0.13",
|
|
34
35
|
"@bsv/wallet-toolbox": "npm:@bopen-io/wallet-toolbox@2.1.21-parity-fix.2",
|
|
35
36
|
"chalk": "^5.0.0",
|
package/src/commands/serve.ts
CHANGED
|
@@ -31,12 +31,18 @@ import {
|
|
|
31
31
|
type ServerAccountsConfig,
|
|
32
32
|
type ServerStorageConfig,
|
|
33
33
|
loadConfig,
|
|
34
|
+
setConfigPath,
|
|
34
35
|
} from '../config'
|
|
35
36
|
import { ensureDataDir } from '../config'
|
|
36
37
|
import { printCommandHelp } from '../help'
|
|
37
38
|
import { loadKey } from '../keys'
|
|
38
39
|
import { clearMonitorPid, writeMonitorPid } from '../monitor-lock'
|
|
39
40
|
import { fatal } from '../output'
|
|
41
|
+
import {
|
|
42
|
+
buildPriceUpdateTask,
|
|
43
|
+
createAccountsConfigLoader,
|
|
44
|
+
resolveRateProvider,
|
|
45
|
+
} from '../repricer'
|
|
40
46
|
import { startMessagebox } from './serve-messagebox'
|
|
41
47
|
|
|
42
48
|
const DEFAULT_HOST = '127.0.0.1'
|
|
@@ -72,6 +78,14 @@ interface ResolvedAccounts {
|
|
|
72
78
|
satsPerUnit: number
|
|
73
79
|
durationBlocks: number
|
|
74
80
|
freeIdentityKeys: string[]
|
|
81
|
+
repricer?: {
|
|
82
|
+
enabled?: boolean
|
|
83
|
+
targetUsd?: number
|
|
84
|
+
intervalMs?: number
|
|
85
|
+
provider?: string
|
|
86
|
+
maxMovePct?: number
|
|
87
|
+
minSats?: number
|
|
88
|
+
}
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
export async function handleServeCommand(
|
|
@@ -217,6 +231,7 @@ function resolveAccounts(accounts?: ServerAccountsConfig): ResolvedAccounts {
|
|
|
217
231
|
satsPerUnit: accounts?.satsPerUnit ?? DEFAULT_SATS_PER_UNIT,
|
|
218
232
|
durationBlocks: accounts?.durationBlocks ?? DEFAULT_DURATION_BLOCKS,
|
|
219
233
|
freeIdentityKeys: accounts?.freeIdentityKeys ?? [],
|
|
234
|
+
repricer: accounts?.repricer,
|
|
220
235
|
}
|
|
221
236
|
}
|
|
222
237
|
|
|
@@ -259,6 +274,42 @@ async function runWithStorage(
|
|
|
259
274
|
? undefined
|
|
260
275
|
: await startWalletServer(resolved, walletResult, accounts)
|
|
261
276
|
|
|
277
|
+
if (mode !== 'wallet' && accounts) {
|
|
278
|
+
const accountsCfg = resolved.accounts
|
|
279
|
+
const r = accountsCfg.repricer
|
|
280
|
+
if (
|
|
281
|
+
accountsCfg.enabled &&
|
|
282
|
+
r?.enabled &&
|
|
283
|
+
typeof r.targetUsd === 'number' &&
|
|
284
|
+
r.targetUsd > 0
|
|
285
|
+
) {
|
|
286
|
+
walletResult.monitor.addTask(
|
|
287
|
+
buildPriceUpdateTask({
|
|
288
|
+
monitor: walletResult.monitor,
|
|
289
|
+
rateProvider: resolveRateProvider(r.provider ?? 'whatsonchain', {
|
|
290
|
+
chain: resolved.chain,
|
|
291
|
+
}),
|
|
292
|
+
intervalMs: r.intervalMs ?? 15 * 60 * 1000,
|
|
293
|
+
targetUsd: r.targetUsd,
|
|
294
|
+
bounds: {
|
|
295
|
+
maxMovePct: r.maxMovePct ?? 25,
|
|
296
|
+
minSats: r.minSats ?? 1,
|
|
297
|
+
},
|
|
298
|
+
readCurrentSats: () =>
|
|
299
|
+
loadConfig().server?.accounts?.satsPerUnit ?? DEFAULT_SATS_PER_UNIT,
|
|
300
|
+
onPersist: async (sats) => {
|
|
301
|
+
setConfigPath('server.accounts.satsPerUnit', sats)
|
|
302
|
+
},
|
|
303
|
+
}),
|
|
304
|
+
)
|
|
305
|
+
console.log(
|
|
306
|
+
`[repricer] enabled — $${r.targetUsd}/unit every ${Math.round(
|
|
307
|
+
(r.intervalMs ?? 900_000) / 1000,
|
|
308
|
+
)}s via ${r.provider ?? 'whatsonchain'}`,
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
262
313
|
if (mode !== 'wallet') {
|
|
263
314
|
// startTasks loops until stopTasks flips its flag. Fire without
|
|
264
315
|
// awaiting so the caller can install shutdown handlers and write
|
|
@@ -318,16 +369,25 @@ async function buildAccountsForServer(
|
|
|
318
369
|
// storage; accounts semantics don't apply.
|
|
319
370
|
if (resolved.activeRemote) return undefined
|
|
320
371
|
|
|
372
|
+
const getConfig = createAccountsConfigLoader({
|
|
373
|
+
ttlMs: 60_000,
|
|
374
|
+
read: () => {
|
|
375
|
+
const fresh = loadConfig()
|
|
376
|
+
const a = fresh.server?.accounts
|
|
377
|
+
return {
|
|
378
|
+
enabled: a?.enabled ?? false,
|
|
379
|
+
baselineBytes: a?.baselineBytes ?? DEFAULT_BASELINE_BYTES,
|
|
380
|
+
purchaseUnitBytes: a?.purchaseUnitBytes ?? DEFAULT_PURCHASE_UNIT_BYTES,
|
|
381
|
+
satsPerUnit: a?.satsPerUnit ?? DEFAULT_SATS_PER_UNIT,
|
|
382
|
+
durationBlocks: a?.durationBlocks ?? DEFAULT_DURATION_BLOCKS,
|
|
383
|
+
freeIdentityKeys: a?.freeIdentityKeys ?? [],
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
|
|
321
388
|
return {
|
|
322
389
|
walletServerAccounts: {
|
|
323
|
-
|
|
324
|
-
enabled: resolved.accounts.enabled,
|
|
325
|
-
baselineBytes: resolved.accounts.baselineBytes,
|
|
326
|
-
purchaseUnitBytes: resolved.accounts.purchaseUnitBytes,
|
|
327
|
-
satsPerUnit: resolved.accounts.satsPerUnit,
|
|
328
|
-
durationBlocks: resolved.accounts.durationBlocks,
|
|
329
|
-
freeIdentityKeys: resolved.accounts.freeIdentityKeys,
|
|
330
|
-
},
|
|
390
|
+
getConfig,
|
|
331
391
|
currentBlock: () => walletResult.services.chaintracks.currentHeight(),
|
|
332
392
|
},
|
|
333
393
|
}
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,21 @@ export type ServerStorageConfig =
|
|
|
25
25
|
| ServerStorageBunSqliteConfig
|
|
26
26
|
| ServerStoragePgConfig
|
|
27
27
|
|
|
28
|
+
export interface RepricerConfig {
|
|
29
|
+
/** Master toggle. Defaults to false. */
|
|
30
|
+
enabled?: boolean
|
|
31
|
+
/** Target price per `purchaseUnitBytes` chunk, in USD. */
|
|
32
|
+
targetUsd?: number
|
|
33
|
+
/** Interval between rate checks in ms. Defaults to 900000 (15 min). */
|
|
34
|
+
intervalMs?: number
|
|
35
|
+
/** Rate provider name. Defaults to "whatsonchain". */
|
|
36
|
+
provider?: string
|
|
37
|
+
/** Max percent change allowed per update. Larger moves are skipped. Defaults to 25. */
|
|
38
|
+
maxMovePct?: number
|
|
39
|
+
/** Lower bound for `satsPerUnit`. Defaults to 1. */
|
|
40
|
+
minSats?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
export interface ServerAccountsConfig {
|
|
29
44
|
/** Master toggle. Defaults to false when omitted. */
|
|
30
45
|
enabled?: boolean
|
|
@@ -38,6 +53,8 @@ export interface ServerAccountsConfig {
|
|
|
38
53
|
durationBlocks?: number
|
|
39
54
|
/** Identity keys that bypass metering (server's own key is auto-added). */
|
|
40
55
|
freeIdentityKeys?: string[]
|
|
56
|
+
/** Optional auto-repricer. When enabled, updates `satsPerUnit` from a live BSV/USD rate. */
|
|
57
|
+
repricer?: RepricerConfig
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
export interface ServerMessageboxConfig {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { computeReprice } from './computeReprice'
|
|
2
|
+
import type { RateProvider, RepricerBounds } from './types'
|
|
3
|
+
|
|
4
|
+
export interface PriceUpdateTaskOptions {
|
|
5
|
+
monitor: unknown
|
|
6
|
+
rateProvider: RateProvider
|
|
7
|
+
/** Interval between successful tick attempts. */
|
|
8
|
+
intervalMs: number
|
|
9
|
+
/** USD target per purchase unit. Task no-ops when this is 0. */
|
|
10
|
+
targetUsd: number
|
|
11
|
+
bounds: RepricerBounds
|
|
12
|
+
/** Read current `satsPerUnit` (typically from disk via the same loader the servers use). */
|
|
13
|
+
readCurrentSats: () => number
|
|
14
|
+
/** Persist the new `satsPerUnit` value. Wraps `setConfigPath('server.accounts.satsPerUnit', sats)`. */
|
|
15
|
+
onPersist: (newSats: number) => Promise<void>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PriceUpdateTask {
|
|
19
|
+
monitor: unknown
|
|
20
|
+
storage: unknown
|
|
21
|
+
name: 'PriceUpdate'
|
|
22
|
+
lastRunMsecsSinceEpoch: number
|
|
23
|
+
asyncSetup(): Promise<void>
|
|
24
|
+
trigger(now: number): { run: boolean }
|
|
25
|
+
runTask(): Promise<string>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildPriceUpdateTask(
|
|
29
|
+
options: PriceUpdateTaskOptions,
|
|
30
|
+
): PriceUpdateTask {
|
|
31
|
+
const {
|
|
32
|
+
monitor,
|
|
33
|
+
rateProvider,
|
|
34
|
+
intervalMs,
|
|
35
|
+
targetUsd,
|
|
36
|
+
bounds,
|
|
37
|
+
readCurrentSats,
|
|
38
|
+
onPersist,
|
|
39
|
+
} = options
|
|
40
|
+
|
|
41
|
+
const storage = (monitor as { storage?: unknown } | null | undefined)?.storage
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
monitor,
|
|
45
|
+
storage,
|
|
46
|
+
name: 'PriceUpdate',
|
|
47
|
+
lastRunMsecsSinceEpoch: 0,
|
|
48
|
+
async asyncSetup() {},
|
|
49
|
+
trigger(now: number): { run: boolean } {
|
|
50
|
+
if (!(targetUsd > 0)) return { run: false }
|
|
51
|
+
if (now - this.lastRunMsecsSinceEpoch < intervalMs) return { run: false }
|
|
52
|
+
return { run: true }
|
|
53
|
+
},
|
|
54
|
+
async runTask(): Promise<string> {
|
|
55
|
+
let quote: Awaited<ReturnType<RateProvider['getBsvUsd']>>
|
|
56
|
+
try {
|
|
57
|
+
quote = await rateProvider.getBsvUsd()
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return `rate fetch failed: ${(err as Error).message}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const currentSats = readCurrentSats()
|
|
63
|
+
const result = computeReprice({
|
|
64
|
+
targetUsd,
|
|
65
|
+
bsvUsd: quote.bsvUsd,
|
|
66
|
+
currentSats,
|
|
67
|
+
bounds,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (result.status === 'skipped') return `skipped: ${result.reason}`
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await onPersist(result.newSats)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return `persist failed: ${(err as Error).message}`
|
|
76
|
+
}
|
|
77
|
+
return `${currentSats} → ${result.newSats} sats/unit @ $${quote.bsvUsd}/BSV`
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComputeRepriceInput, ComputeRepriceResult } from './types'
|
|
2
|
+
|
|
3
|
+
const SATS_PER_BSV = 100_000_000
|
|
4
|
+
|
|
5
|
+
export function computeReprice(
|
|
6
|
+
input: ComputeRepriceInput,
|
|
7
|
+
): ComputeRepriceResult {
|
|
8
|
+
const { targetUsd, bsvUsd, currentSats, bounds } = input
|
|
9
|
+
|
|
10
|
+
if (!(targetUsd > 0))
|
|
11
|
+
return { status: 'skipped', reason: 'targetUsd must be > 0' }
|
|
12
|
+
if (!(bsvUsd > 0)) return { status: 'skipped', reason: 'bsvUsd must be > 0' }
|
|
13
|
+
if (!(currentSats > 0))
|
|
14
|
+
return { status: 'skipped', reason: 'currentSats must be > 0' }
|
|
15
|
+
|
|
16
|
+
const newSats = Math.round((targetUsd / bsvUsd) * SATS_PER_BSV)
|
|
17
|
+
|
|
18
|
+
if (newSats < bounds.minSats) {
|
|
19
|
+
return {
|
|
20
|
+
status: 'skipped',
|
|
21
|
+
reason: `computed ${newSats} is below minSats ${bounds.minSats}`,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const movePct = (Math.abs(newSats - currentSats) / currentSats) * 100
|
|
26
|
+
if (movePct > bounds.maxMovePct) {
|
|
27
|
+
return {
|
|
28
|
+
status: 'skipped',
|
|
29
|
+
reason: `move ${movePct.toFixed(2)}% exceeds maxMovePct ${bounds.maxMovePct}`,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { status: 'ok', newSats }
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountsConfig,
|
|
3
|
+
AccountsConfigProvider,
|
|
4
|
+
} from '@1sat/wallet-server'
|
|
5
|
+
|
|
6
|
+
export interface AccountsConfigLoaderOptions {
|
|
7
|
+
ttlMs: number
|
|
8
|
+
read: () => AccountsConfig
|
|
9
|
+
/** Override clock (testing). */
|
|
10
|
+
now?: () => number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build an `AccountsConfigProvider` that re-reads its source no more often
|
|
15
|
+
* than `ttlMs`. If `read` throws after the first success, the last good
|
|
16
|
+
* value is returned and the failure is silently absorbed.
|
|
17
|
+
*/
|
|
18
|
+
export function createAccountsConfigLoader(
|
|
19
|
+
options: AccountsConfigLoaderOptions,
|
|
20
|
+
): AccountsConfigProvider {
|
|
21
|
+
const now = options.now ?? (() => Date.now())
|
|
22
|
+
let cached: AccountsConfig | undefined
|
|
23
|
+
let cachedAt = 0
|
|
24
|
+
|
|
25
|
+
return () => {
|
|
26
|
+
const t = now()
|
|
27
|
+
if (cached && t - cachedAt < options.ttlMs) return cached
|
|
28
|
+
try {
|
|
29
|
+
cached = options.read()
|
|
30
|
+
cachedAt = t
|
|
31
|
+
return cached
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (cached) return cached
|
|
34
|
+
throw err
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { buildPriceUpdateTask } from './buildPriceUpdateTask'
|
|
2
|
+
export type { PriceUpdateTaskOptions } from './buildPriceUpdateTask'
|
|
3
|
+
export { computeReprice } from './computeReprice'
|
|
4
|
+
export { createAccountsConfigLoader } from './configLoader'
|
|
5
|
+
export type { AccountsConfigLoaderOptions } from './configLoader'
|
|
6
|
+
export { resolveRateProvider } from './providers'
|
|
7
|
+
export { createWhatsOnChainProvider } from './whatsOnChain'
|
|
8
|
+
export type {
|
|
9
|
+
BsvUsdQuote,
|
|
10
|
+
ComputeRepriceInput,
|
|
11
|
+
ComputeRepriceResult,
|
|
12
|
+
RateProvider,
|
|
13
|
+
RepricerBounds,
|
|
14
|
+
} from './types'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RateProvider } from './types'
|
|
2
|
+
import { createWhatsOnChainProvider } from './whatsOnChain'
|
|
3
|
+
|
|
4
|
+
export interface ResolveProviderOptions {
|
|
5
|
+
chain: 'main' | 'test'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function resolveRateProvider(
|
|
9
|
+
name: string,
|
|
10
|
+
options: ResolveProviderOptions,
|
|
11
|
+
): RateProvider {
|
|
12
|
+
switch (name) {
|
|
13
|
+
case 'whatsonchain':
|
|
14
|
+
return createWhatsOnChainProvider({ chain: options.chain })
|
|
15
|
+
default:
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Unknown rate provider "${name}". Supported: whatsonchain.`,
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface BsvUsdQuote {
|
|
2
|
+
bsvUsd: number
|
|
3
|
+
timestamp: number
|
|
4
|
+
source: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RateProvider {
|
|
8
|
+
readonly name: string
|
|
9
|
+
getBsvUsd(): Promise<BsvUsdQuote>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RepricerBounds {
|
|
13
|
+
/** Reject moves larger than this percent. */
|
|
14
|
+
maxMovePct: number
|
|
15
|
+
/** Floor for the new sats value. */
|
|
16
|
+
minSats: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ComputeRepriceInput {
|
|
20
|
+
targetUsd: number
|
|
21
|
+
bsvUsd: number
|
|
22
|
+
currentSats: number
|
|
23
|
+
bounds: RepricerBounds
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ComputeRepriceResult =
|
|
27
|
+
| { status: 'ok'; newSats: number }
|
|
28
|
+
| { status: 'skipped'; reason: string }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BsvUsdQuote, RateProvider } from './types'
|
|
2
|
+
|
|
3
|
+
export interface WhatsOnChainOptions {
|
|
4
|
+
chain: 'main' | 'test'
|
|
5
|
+
/** Override base URL (testing). */
|
|
6
|
+
baseUrl?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createWhatsOnChainProvider(
|
|
10
|
+
options: WhatsOnChainOptions,
|
|
11
|
+
): RateProvider {
|
|
12
|
+
const base = options.baseUrl ?? 'https://api.whatsonchain.com/v1/bsv'
|
|
13
|
+
const url = `${base}/${options.chain}/exchangerate`
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
name: 'whatsonchain',
|
|
17
|
+
async getBsvUsd(): Promise<BsvUsdQuote> {
|
|
18
|
+
const res = await fetch(url)
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`whatsonchain: ${res.status} ${await res.text().catch(() => '')}`,
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
const body = (await res.json()) as { rate?: number; time?: number }
|
|
25
|
+
if (typeof body.rate !== 'number' || !(body.rate > 0)) {
|
|
26
|
+
throw new Error('whatsonchain: missing or invalid "rate" in response')
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
bsvUsd: body.rate,
|
|
30
|
+
timestamp:
|
|
31
|
+
typeof body.time === 'number' ? body.time * 1000 : Date.now(),
|
|
32
|
+
source: 'whatsonchain',
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|